diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 063450c..9f1eb41 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: '35' + MIN_COVERAGE: '55' steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 3da47c2..bd77434 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /.phpunit.cache/ /storage/ /public/_code/ +/var/ # Third-party/commercial theme modules are their OWN (private) repos, never inside tiger-core. # They install into the app modules dir (application/modules/theme-/), not here. diff --git a/library/Tiger/Model/AgentMessage.php b/library/Tiger/Model/AgentMessage.php index f7b4971..451d6e1 100644 --- a/library/Tiger/Model/AgentMessage.php +++ b/library/Tiger/Model/AgentMessage.php @@ -32,7 +32,7 @@ class Tiger_Model_AgentMessage extends Tiger_Model_Table * @param string $runId the turn that produced it ('' for a bare user message) * @return string the new message_id */ - public function append($conversationId, $role, $content, array $meta = null, $runId = '') + public function append($conversationId, $role, $content, ?array $meta = null, $runId = '') { return $this->insert([ 'conversation_id' => (string) $conversationId, diff --git a/tests/COVERAGE-PLAN.md b/tests/COVERAGE-PLAN.md index 6177507..9e24a5c 100644 --- a/tests/COVERAGE-PLAN.md +++ b/tests/COVERAGE-PLAN.md @@ -594,14 +594,29 @@ cluster ~2–8×'d (`Dependency` 97 · `License_Authority` 94 · `Vendor` 80 · full MVC/view boot the harness skips. These are functional/HTTP-test concerns — the % gaps below ~90 on media/profile/backup are mostly these, not missing logic. +### Wave 5 — the library/Tiger KERNEL (LANDED 2026-07-24) → coverage 37.9% → 59.2% +7 parallel agents by sub-package, **+~584 tests (861 → 1445 combined green)**, kernel **library/Tiger 32% → 66%**. +Per-subpackage lines: Model 51→97 · Service 57→89 · Ajax 70→89 · Controller 16→77 · Admin 0→81 · Session 0→96 · +View 0→98 · OpenApi 0→94 · Generator 0→100 · Policy 85→100 · Acl 85→89 · Location 7→84 · I18n→95 · Validate 21→74 · +Media(lib) 25→~85 · Backup 0→~90 · Code(lib) 44→~85 · Agent(lib) 3→~72 · Google 0→29 · Application 0→60. **No behavioral +bugs.** One forward-compat source fix: `Tiger_Model_AgentMessage::append()` implicit-nullable `array $meta=null` → `?array` +(PHP 8.5 deprecation, error in PHP 9). **Remaining kernel gap (~3,150 lines) is the genuinely-hard I/O:** the live-boot +`Tiger_Application_Bootstrap::_init*` orchestration, `Update_Composer`/`Update_Core` (composer proc_open + `vendor/` swap), +provider/authority/GA/reCAPTCHA live HTTP, ClamAV/Rekognition scanners, AWS-SDK S3 I/O — all functional/live territory. +- **Cross-test isolation footguns** the agents surfaced (each guarded locally; recorded for future waves): `Tiger_Model_Org::$_siteOrgId` + process-static must be reset to EXACTLY `null` (restoring `''` poisons lazy site-org resolution); `Zend_Translate` + set to null in the registry stays registered-as-null → use `offsetUnset`, not `set(null)`; `Tiger_Application::defineConstants()` + mints `MODULES_PATH` etc. → the app-boot test runs `#[RunInSeparateProcess]`. **RECOMMENDED base hygiene (deferred):** reset + `Org::$_siteOrgId` in `IntegrationTestCase::tearDown`. CI floor `MIN_COVERAGE` 35 → 55. + ### Next waves (priority order) — the drive to 90% -- **Wave 5 — the library/Tiger KERNEL** (9,176 lines @ 32% = the dominant remaining gap, ~6,200 uncovered): - the CMS engine (`Cms_*`, `Menu`, `Page*` finders), `Mail`, `Log`, `Location` adapters, `Admin_*` registries, - `Form`/`Validate`/`Form_Element_*`, `Ajax_ServiceFactory` remainder, and the untested branches of the Model - + Service classes. This is what actually moves the overall number to the 80s. Parallel agents by sub-package. -- **Wave 6 — the remaining modules:** `agent` (462 @ 5% — needs a provider-adapter stub), `cms` module (510 @ - 33%), `code` (238 @ 41%), and `core/controllers` (360 @ 0% — needs the controller-dispatch harness). -- **Wave 7 — satellite repos:** stand up a harness in each, then TigerShield WAF engines first. +- **Wave 6 — the remaining MODULES:** `agent` module (462 @ 5% — needs a provider-adapter stub, but the `Tiger_Agent_*` + library it builds on is now ~72% so the seams exist), `cms` module (510 @ 33%), `system` remainder (579 @ 37%), `code` + module (238 @ 41%), profile/media/analytics/backup/identity remainders (mostly upload-ceiling + render-only), and + `core/controllers` (360 @ 0% — needs a controller-dispatch harness). ~2,700 reachable module lines. +- **Wave 7 — the hard kernel remainder:** an integration harness that boots enough of `Tiger_Application` to cover the + `_init*` cascade; live-I/O seams for Update/Github behind a real local fixture server if worth it. +- **Wave 8 — satellite repos:** stand up a harness in each, then TigerShield WAF engines first. --- *Manifest generated 2026-07-18 by 6 parallel read-only scans; graduated into `tests/COVERAGE-PLAN.md` + diff --git a/tests/Integration/Agent/ForgeAclTest.php b/tests/Integration/Agent/ForgeAclTest.php new file mode 100644 index 0000000..eae71eb --- /dev/null +++ b/tests/Integration/Agent/ForgeAclTest.php @@ -0,0 +1,235 @@ +target !== '' ? $this->target : null; } +} + +/** + * Tiger_Agent_Forge — the permission-gated "hands" of the agent, exercised against the REAL shipped + * ACL (core + every module's acl.ini, via the integration login). It verifies the twin gate: the ACL + * (deny-by-default, the same rule a human hits) and the human-in-the-loop rule (writes wait for an + * explicit approval). The tiers fall straight out of the agent acl.ini — code/file at superadmin, + * module at developer — so a role that lacks the privilege is refused, and an allowed-but-unapproved + * write is deferred, never run. The live /api dispatch of an approved read/write is the documented + * boundary (it would run a real downstream service), so it isn't driven here. + */ +#[CoversClass(Tiger_Agent_Forge::class)] +final class ForgeAclTest extends IntegrationTestCase +{ + #[Test] + public function anUnknownActionTypeIsACleanError(): void + { + $this->loginAs('developer'); + $entry = (new Tiger_Agent_Forge('developer'))->execute(['type' => 'nope', 'reason' => 'x']); + $this->assertSame('error', $entry['status']); + $this->assertStringContainsString('Unknown action type', $entry['summary']); + } + + // ----- api tier --------------------------------------------------------- + + #[Test] + public function aGuestIsDeniedAnApiWrite(): void + { + $this->loginAs('guest'); + $entry = (new Tiger_Agent_Forge('guest'))->execute([ + 'type' => 'api', 'module' => 'access', 'service' => 'user', 'method' => 'create', 'params' => [], 'reason' => 'r', + ]); + $this->assertSame('denied', $entry['status']); + $this->assertArrayHasKey('action', $entry); // the proposal is preserved for the ledger + } + + #[Test] + public function anAllowedButUnapprovedApiWriteIsDeferredNotDispatched(): void + { + $this->loginAs('admin'); // admin may call Cms_Service_Page (the real cms acl.ini) + $entry = (new Tiger_Agent_Forge('admin'))->execute([ + 'type' => 'api', 'module' => 'cms', 'service' => 'page', 'method' => 'save', + 'params' => ['title' => 'FAQ'], 'reason' => 'create the page', + ]); + $this->assertSame('proposed', $entry['status']); + $this->assertStringContainsString('cms/page/save', $entry['summary']); + } + + #[Test] + public function anApprovedApiWriteDispatchesTheRealServiceAndReportsItsResult(): void + { + $this->loginAs('admin'); + // Approved → the Forge dispatches Cms_Service_Page for real. Empty params fail the page form, + // so nothing persists, but the dispatch + response-message flattening + ledger entry all run. + $entry = (new Tiger_Agent_Forge('admin'))->execute([ + 'type' => 'api', 'module' => 'cms', 'service' => 'page', 'method' => 'save', + 'params' => [], 'reason' => 'create a page', 'approved' => true, + ]); + $this->assertContains($entry['status'], ['done', 'error']); // it dispatched, whatever the verdict + $this->assertStringContainsString('cms/page/save', $entry['summary']); + $this->assertArrayHasKey('result', $entry['detail']); + } + + // ----- code tier (superadmin+) ------------------------------------------ + + #[Test] + public function writingExecutableCodeIsDeniedBelowSuperadmin(): void + { + $this->loginAs('manager'); + $entry = (new Tiger_Agent_Forge('manager'))->execute([ + 'type' => 'code', 'name' => 's', 'language' => 'php', 'code' => ' 'r', + ]); + $this->assertSame('denied', $entry['status']); + } + + #[Test] + public function anAllowedButUnapprovedCodeWriteIsDeferredNotRun(): void + { + $this->loginAs('superadmin'); + $entry = (new Tiger_Agent_Forge('superadmin'))->execute([ + 'type' => 'code', 'name' => 'helper', 'language' => 'php', 'code' => ' 'r', + ]); + $this->assertSame('proposed', $entry['status']); + $this->assertStringContainsString('helper', $entry['summary']); + } + + // ----- file tier (superadmin+) ------------------------------------------ + + #[Test] + public function writingAModuleFileIsDeniedBelowSuperadmin(): void + { + $this->loginAs('admin'); // admin has inventory, but NOT the sharp file tier + $entry = (new Tiger_Agent_Forge('admin'))->execute([ + 'type' => 'file', 'path' => 'demo/x.phtml', 'contents' => '

x

', 'reason' => 'r', + ]); + $this->assertSame('denied', $entry['status']); + } + + #[Test] + public function anAllowedButUnapprovedFileWriteIsDeferred(): void + { + $this->loginAs('superadmin'); + $entry = (new Tiger_Agent_Forge('superadmin'))->execute([ + 'type' => 'file', 'path' => 'demo/x.phtml', 'contents' => '

x

', 'reason' => 'r', + ]); + $this->assertSame('proposed', $entry['status']); + $this->assertStringContainsString('demo/x.phtml', $entry['summary']); + } + + #[Test] + public function anApprovedPhpFileThatWouldNotParseIsRefusedBeforeItHitsDisk(): void + { + $this->loginAs('superadmin'); + $tmp = sys_get_temp_dir() . '/tiger-forge-' . uniqid() . '/broken.php'; + + $forge = new TempForge('superadmin'); + $forge->target = $tmp; + $entry = $forge->execute([ + 'type' => 'file', 'path' => 'demo/broken.php', 'contents' => ' 'r', 'approved' => true, + ]); + + $this->assertSame('error', $entry['status']); + $this->assertStringContainsString('would not parse', $entry['summary']); + $this->assertFileDoesNotExist($tmp); // the lint gate refuses it before any write + } + + #[Test] + public function anApprovedFileWriteLandsOnDiskInsideTheSandbox(): void + { + $this->loginAs('superadmin'); + $tmp = sys_get_temp_dir() . '/tiger-forge-' . uniqid() . '/nested/view.phtml'; + + $forge = new TempForge('superadmin'); + $forge->target = $tmp; + + // A successful write emits a Tiger_Log line via the ErrorLog writer (→ stderr), which the + // strict "no unexpected output" rule flags risky. Point the logger at the Null writer for the + // duration of the write, then restore the registry EXACTLY as it was. + $had = Zend_Registry::isRegistered('Zend_Config'); + $prev = $had ? Zend_Registry::get('Zend_Config') : null; + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['log' => ['writer' => 'null']]])); + Tiger_Log::reset(); + try { + $entry = $forge->execute([ + 'type' => 'file', 'path' => 'demo/nested/view.phtml', 'contents' => '

hello

', + 'reason' => 'r', 'approved' => true, + ]); + } finally { + if ($had) { Zend_Registry::set('Zend_Config', $prev); } + else { Zend_Registry::getInstance()->offsetUnset('Zend_Config'); } + Tiger_Log::reset(); + } + + $this->assertSame('done', $entry['status']); + $this->assertFileExists($tmp); + $this->assertSame('

hello

', file_get_contents($tmp)); + + @unlink($tmp); + @rmdir(dirname($tmp)); + @rmdir(dirname($tmp, 2)); + } + + // ----- module tier (developer only) ------------------------------------- + + #[Test] + public function scaffoldingAModuleIsDeniedBelowDeveloper(): void + { + $this->loginAs('superadmin'); // superadmin has file, but module is developer-only + $entry = (new Tiger_Agent_Forge('superadmin'))->execute([ + 'type' => 'module', 'name' => 'widgets', 'reason' => 'r', + ]); + $this->assertSame('denied', $entry['status']); + } + + #[Test] + public function anAllowedButUnapprovedModuleScaffoldIsDeferred(): void + { + $this->loginAs('developer'); + $entry = (new Tiger_Agent_Forge('developer'))->execute([ + 'type' => 'module', 'name' => 'widgets', 'reason' => 'r', + ]); + $this->assertSame('proposed', $entry['status']); + $this->assertStringContainsString('widgets', $entry['summary']); + } + + #[Test] + public function anApprovedModuleScaffoldWithAnEmptyNameIsAnError(): void + { + $this->loginAs('developer'); + $entry = (new Tiger_Agent_Forge('developer'))->execute([ + 'type' => 'module', 'name' => '', 'reason' => 'r', 'approved' => true, + ]); + $this->assertSame('error', $entry['status']); + $this->assertStringContainsString('valid module name', $entry['summary']); + } + + #[Test] + public function anApprovedModuleScaffoldRefusesAnExistingModuleName(): void + { + $this->loginAs('developer'); + // 'agent' already exists under the modules root → the Forge refuses to clobber it. + $entry = (new Tiger_Agent_Forge('developer'))->execute([ + 'type' => 'module', 'name' => 'agent', 'reason' => 'r', 'approved' => true, + ]); + $this->assertSame('error', $entry['status']); + $this->assertStringContainsString('already exists', $entry['summary']); + } +} diff --git a/tests/Integration/Agent/ScoutTest.php b/tests/Integration/Agent/ScoutTest.php new file mode 100644 index 0000000..3a496f4 --- /dev/null +++ b/tests/Integration/Agent/ScoutTest.php @@ -0,0 +1,181 @@ +loginAs('admin'); + $entry = (new Tiger_Agent_Scout('admin'))->execute(['type' => 'read.inventory', 'reason' => 'r']); + $this->assertSame('done', $entry['status']); + $this->assertStringContainsString('MODULES:', $entry['feedback']); + $this->assertStringContainsString('ROOTS', $entry['feedback']); + } + + #[Test] + public function treeWithNoPathMapsTheReadRootsWithPermissions(): void + { + $this->loginAs('developer'); + $entry = (new Tiger_Agent_Scout('developer'))->execute(['type' => 'read.tree', 'reason' => 'r']); + $this->assertSame('done', $entry['status']); + $this->assertStringContainsString('ROOT MAP', $entry['feedback']); + } + + #[Test] + public function treeListsEntriesUnderAScopedPath(): void + { + $this->loginAs('developer'); + $entry = (new Tiger_Agent_Scout('developer'))->execute(['type' => 'read.tree', 'path' => 'library/Tiger/Agent', 'reason' => 'r']); + $this->assertSame('done', $entry['status']); + $this->assertStringContainsString('Contract.php', $entry['feedback']); + $this->assertStringContainsString('TREE', $entry['feedback']); + } + + #[Test] + public function fileReturnsABoundedFileBody(): void + { + $this->loginAs('superadmin'); + $entry = (new Tiger_Agent_Scout('superadmin'))->execute(['type' => 'read.file', 'path' => 'library/Tiger/Agent/Contract.php', 'reason' => 'r']); + $this->assertSame('done', $entry['status']); + $this->assertStringContainsString('class Tiger_Agent_Contract', $entry['feedback']); + } + + #[Test] + public function fileOutsideTheReadableSurfaceIsRefused(): void + { + $this->loginAs('developer'); + $entry = (new Tiger_Agent_Scout('developer'))->execute(['type' => 'read.file', 'path' => 'does/not/exist.php', 'reason' => 'r']); + $this->assertSame('denied', $entry['status']); + } + + #[Test] + public function grepFindsAStringUnderAScopedPathAndReportsMisses(): void + { + $this->loginAs('developer'); + $scout = new Tiger_Agent_Scout('developer'); + + $hit = $scout->execute(['type' => 'read.grep', 'query' => 'READ_INVENTORY', 'path' => 'library/Tiger/Agent', 'reason' => 'r']); + $this->assertSame('done', $hit['status']); + $this->assertStringContainsString('match', $hit['feedback']); + + $miss = $scout->execute(['type' => 'read.grep', 'query' => 'zzz_no_such_token_zzz', 'path' => 'library/Tiger/Agent', 'reason' => 'r']); + $this->assertStringContainsString('no matches', $miss['feedback']); + } + + #[Test] + public function grepWithoutAQueryIsAnError(): void + { + $this->loginAs('developer'); + $entry = (new Tiger_Agent_Scout('developer'))->execute(['type' => 'read.grep', 'query' => '', 'reason' => 'r']); + $this->assertSame('error', $entry['status']); + } + + #[Test] + public function guideReturnsThePlatformConventionsWithNoModule(): void + { + $this->loginAs('developer'); + $entry = (new Tiger_Agent_Scout('developer'))->execute(['type' => 'read.guide', 'module' => '', 'reason' => 'r']); + $this->assertSame('done', $entry['status']); + $this->assertStringContainsString('PLATFORM CONVENTIONS', $entry['feedback']); + } + + #[Test] + public function guideForAModuleThatShipsAnAgentsMdReturnsIt(): void + { + $this->loginAs('developer'); + // The cms module ships an AGENTS.md → the Scout returns that module guide directly. + $entry = (new Tiger_Agent_Scout('developer'))->execute(['type' => 'read.guide', 'module' => 'cms', 'reason' => 'r']); + $this->assertSame('done', $entry['status']); + $this->assertStringContainsString('GUIDE for module "cms"', $entry['feedback']); + } + + #[Test] + public function treeOnAFilePathReportsItIsAFile(): void + { + $this->loginAs('developer'); + $entry = (new Tiger_Agent_Scout('developer'))->execute(['type' => 'read.tree', 'path' => 'library/Tiger/Agent/Contract.php', 'reason' => 'r']); + $this->assertSame('done', $entry['status']); + $this->assertStringContainsString('is a file', $entry['summary']); + } + + #[Test] + public function treeMarksTheModulesRootWritableByTheForge(): void + { + $this->loginAs('developer'); + // A path under the app-modules root (MODULES_PATH) reports the 'rw' permission the agent needs. + $entry = (new Tiger_Agent_Scout('developer'))->execute(['type' => 'read.tree', 'path' => 'modules/agent', 'reason' => 'r']); + $this->assertSame('done', $entry['status']); + $this->assertStringContainsString('WRITABLE by the Forge', $entry['feedback']); + } + + #[Test] + public function guideForAModuleWithoutAnAgentsMdFallsBackToTheConventions(): void + { + $this->loginAs('developer'); + // The agent module ships no AGENTS.md, so the Scout returns the platform conventions with a note. + $entry = (new Tiger_Agent_Scout('developer'))->execute(['type' => 'read.guide', 'module' => 'agent', 'reason' => 'r']); + $this->assertSame('done', $entry['status']); + $this->assertStringContainsString('platform conventions', $entry['feedback']); + } + + #[Test] + public function anUnknownReadActionIsAnError(): void + { + $this->loginAs('developer'); + $entry = (new Tiger_Agent_Scout('developer'))->execute(['type' => 'read.bogus', 'reason' => 'r']); + $this->assertSame('error', $entry['status']); + } + + // ----- denied (the tier gate) ------------------------------------------- + + #[Test] + public function aManagerIsDeniedEveryReadTierIncludingInventory(): void + { + $this->loginAs('manager'); // may chat, but has neither inventory nor read + $scout = new Tiger_Agent_Scout('manager'); + foreach ([ + ['type' => 'read.inventory'], + ['type' => 'read.tree'], + ['type' => 'read.file', 'path' => 'library/Tiger/Agent/Contract.php'], + ['type' => 'read.grep', 'query' => 'x'], + ['type' => 'read.guide'], + ] as $action) { + $this->assertSame('denied', $scout->execute($action + ['reason' => 'r'])['status'], $action['type'] . ' should be denied'); + } + } + + #[Test] + public function anAdminGetsInventoryButIsDeniedTheDeeperReadTier(): void + { + $this->loginAs('admin'); // inventory yes, tree/file/grep no + $scout = new Tiger_Agent_Scout('admin'); + $this->assertSame('done', $scout->execute(['type' => 'read.inventory', 'reason' => 'r'])['status']); + $this->assertSame('denied', $scout->execute(['type' => 'read.tree', 'reason' => 'r'])['status']); + $this->assertSame('denied', $scout->execute(['type' => 'read.file', 'path' => 'x', 'reason' => 'r'])['status']); + } +} diff --git a/tests/Integration/Agent/ToolsCatalogTest.php b/tests/Integration/Agent/ToolsCatalogTest.php new file mode 100644 index 0000000..933807f --- /dev/null +++ b/tests/Integration/Agent/ToolsCatalogTest.php @@ -0,0 +1,73 @@ +loginAs('developer'); + $catalog = Tiger_Agent_Tools::catalog('developer'); + + $this->assertIsArray($catalog); + // The agent never advertises its own plumbing service surface. + $this->assertArrayNotHasKey('agent', $catalog); + + // Each group is a list of {service, method, summary} rows. + foreach ($catalog as $module => $ops) { + $this->assertIsString($module); + $this->assertNotEmpty($ops); + $this->assertArrayHasKey('service', $ops[0]); + $this->assertArrayHasKey('method', $ops[0]); + $this->assertArrayHasKey('summary', $ops[0]); + } + } + + #[Test] + public function aGuestSeesAtMostWhatADeveloperSees(): void + { + $this->loginAs('developer'); + $devCount = $this->flatCount(Tiger_Agent_Tools::catalog('developer')); + + $this->loginAs('guest'); + $guestCount = $this->flatCount(Tiger_Agent_Tools::catalog('guest')); + + // Deny-by-default: the guest's callable surface is never larger than the developer's. + $this->assertLessThanOrEqual($devCount, $guestCount); + } + + #[Test] + public function systemPromptOverTheLiveAclProducesAUsablePrompt(): void + { + $this->loginAs('developer'); + $prompt = Tiger_Agent_Tools::systemPrompt('developer', + ['inventory' => true, 'read' => true, 'api' => true, 'file' => true, 'module' => true], ['path' => '/'], 'auto'); + + $this->assertStringContainsString('You are TigerAgent', $prompt); + $this->assertStringContainsString('CALLABLE /api CATALOG', $prompt); + $this->assertStringContainsString('AUTO —', $prompt); + } + + /** Total callable operations across all module groups. */ + private function flatCount(array $catalog): int + { + $n = 0; + foreach ($catalog as $ops) { $n += count($ops); } + return $n; + } +} diff --git a/tests/Integration/Backup/DatabaseTest.php b/tests/Integration/Backup/DatabaseTest.php new file mode 100644 index 0000000..59d3b5c --- /dev/null +++ b/tests/Integration/Backup/DatabaseTest.php @@ -0,0 +1,183 @@ +tmpFiles as $f) { @unlink($f); } + // Drop any throwaway tables an import() test created (raw — outside the rolled-back txn). + if (isset($this->db)) { + $pdo = $this->db->getConnection(); + foreach ($this->throwaway as $t) { + try { $pdo->exec('DROP TABLE IF EXISTS `' . $t . '`'); } catch (\Throwable $e) {} + } + } + $this->tmpFiles = []; + $this->throwaway = []; + parent::tearDown(); + } + + private function tmpFile(): string + { + $p = tempnam(sys_get_temp_dir(), 'tw5dump') . '.sql'; + $this->tmpFiles[] = $p; + return $p; + } + + /** Assemble a dump body in the exact statement-token framing dump()/import() speak. */ + private function craftDump(string $token, array $statements): string + { + $sep = "\n-- @" . $token . "@\n"; + $sql = "-- TigerBackup SQL dump (test)\n-- TIGER_STMT_TOKEN: {$token}\n"; + $sql .= 'SET NAMES utf8mb4;' . $sep; + foreach ($statements as $s) { + $sql .= $s . $sep; + } + return $sql; + } + + // ---- dump() over the real schema (read-only) ----------------------------------------------- + + #[Test] + public function dump_writes_a_tokened_sql_file_over_the_live_schema(): void + { + $path = $this->tmpFile(); + $meta = Tiger_Backup_Database::dump($path); + + // The tally is sane against the migrated test DB (many tables, the migrations table has rows). + $this->assertArrayHasKey('tables', $meta); + $this->assertArrayHasKey('rows', $meta); + $this->assertArrayHasKey('token', $meta); + $this->assertGreaterThan(0, $meta['tables'], 'the migrated schema has tables to dump'); + $this->assertMatchesRegularExpression('/^[0-9a-f]+$/', $meta['token']); + + $sql = file_get_contents($path); + // The self-describing header the restore keys off + the portability guards. + $this->assertStringContainsString('-- TIGER_STMT_TOKEN: ' . $meta['token'], $sql); + $this->assertStringContainsString('SET FOREIGN_KEY_CHECKS=0', $sql); + $this->assertStringContainsString('SET FOREIGN_KEY_CHECKS=1', $sql); + // Every dumped table is DROP-guarded then recreated. + $this->assertStringContainsString('DROP TABLE IF EXISTS', $sql); + $this->assertStringContainsString('CREATE TABLE', $sql); + // The per-dump statement separator carries the random token (collision-proof against row data). + $this->assertStringContainsString('-- @' . $meta['token'] . '@', $sql); + } + + #[Test] + public function dump_emits_rows_as_chunked_inserts_for_a_populated_table(): void + { + // The migrations ledger is always populated after ensureMigrated(), so its rows must dump. + $path = $this->tmpFile(); + $meta = Tiger_Backup_Database::dump($path); + $sql = file_get_contents($path); + + $this->assertGreaterThan(0, $meta['rows'], 'a migrated DB has at least the migration ledger rows'); + $this->assertStringContainsString('INSERT INTO', $sql, 'row data is emitted as INSERT statements'); + } + + // ---- the restore guard --------------------------------------------------------------------- + + #[Test] + public function import_refuses_a_file_that_is_not_a_tigerbackup_dump(): void + { + $path = $this->tmpFile(); + file_put_contents($path, "-- just some SQL\nSELECT 1;\n"); // no TIGER_STMT_TOKEN header + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/not a TigerBackup SQL dump/i'); + Tiger_Backup_Database::import($path); + } + + #[Test] + public function import_throws_when_the_file_cannot_be_read(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/cannot read/i'); + Tiger_Backup_Database::import(sys_get_temp_dir() . '/w5-no-such-dump-' . bin2hex(random_bytes(4)) . '.sql'); + } + + // ---- a real import() round trip, confined to a throwaway table ------------------------------ + + #[Test] + public function import_executes_a_valid_dump_against_a_throwaway_table(): void + { + $table = 'zzz_w5_selftest'; + $this->throwaway[] = $table; + + $token = bin2hex(random_bytes(6)); + $sql = $this->craftDump($token, [ + 'DROP TABLE IF EXISTS `' . $table . '`', + 'CREATE TABLE `' . $table . '` (id INT PRIMARY KEY, name VARCHAR(50))', + "INSERT INTO `{$table}` (id, name) VALUES (1, 'alpha'), (2, 'bravo')", + ]); + $path = $this->tmpFile(); + file_put_contents($path, $sql); + + $n = Tiger_Backup_Database::import($path); + $this->assertGreaterThanOrEqual(4, $n, 'header/SET + drop + create + insert all executed'); + + // The throwaway table now exists with the inserted rows (raw read — it was implicit-committed). + $pdo = Zend_Db_Table_Abstract::getDefaultAdapter()->getConnection(); + $count = (int) $pdo->query('SELECT COUNT(*) FROM `' . $table . '`')->fetchColumn(); + $this->assertSame(2, $count, 'the restored rows landed'); + } + + #[Test] + public function import_aborts_on_a_broken_statement_and_restores_fk_checks(): void + { + $token = bin2hex(random_bytes(6)); + // A valid statement, then deliberate garbage that PDO can't parse. + $sql = $this->craftDump($token, [ + 'SET @w5 := 1', + 'THIS IS NOT VALID SQL AT ALL', + ]); + $path = $this->tmpFile(); + file_put_contents($path, $sql); + + try { + Tiger_Backup_Database::import($path); + $this->fail('a broken statement must abort the restore'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('restore failed at statement', $e->getMessage()); + } + + // FK checks were re-enabled on the failure path (the catch runs SET FOREIGN_KEY_CHECKS=1). + $pdo = Zend_Db_Table_Abstract::getDefaultAdapter()->getConnection(); + $fk = $pdo->query('SELECT @@FOREIGN_KEY_CHECKS')->fetchColumn(); + $this->assertSame(1, (int) $fk, 'FK checks are restored even when a restore fails'); + } +} diff --git a/tests/Integration/Code/ModulesTest.php b/tests/Integration/Code/ModulesTest.php new file mode 100644 index 0000000..c2deb34 --- /dev/null +++ b/tests/Integration/Code/ModulesTest.php @@ -0,0 +1,181 @@ +/snippets/.php`, a `tiger:snippet` header + a + * define-only body); nothing is ever copied into the DB, and "active" is a small config set + * (`tiger.code.modules`). These tests plant a throwaway fixture module under the harness app path + * (APPLICATION_PATH/modules, removed in tearDown) and exercise the discovery + active-set surface: + * - `all()`/`get()` discover the file and parse its `tiger:snippet` hint (label/category/scope/ + * description — the description spanning a second comment line, proving the multi-line parse); + * - `body()` normalizes the PHP (opening tag stripped) for the compiler; `source()` returns raw bytes; + * - `activeKeys()`/`setActive()`/`isActive()` flip a key in the config set (the live-override tier); + * - `activeForLoad()` returns only ACTIVE snippets whose scope matches the run location (an `admin` + * snippet is excluded from the `global` load). + * + * The config rows `setActive()` writes ride the per-test transaction (rolled back); the fixture files + * are deleted in tearDown. + */ +#[CoversClass(Tiger_Code_Modules::class)] +final class ModulesTest extends IntegrationTestCase +{ + private const SLUG = 'w5codemod'; + + private string $moduleDir; + + protected function setUp(): void + { + parent::setUp(); + $this->moduleDir = APPLICATION_PATH . '/modules/' . self::SLUG; + $snip = $this->moduleDir . '/snippets'; + @mkdir($snip, 0777, true); + + // A global-scope snippet whose description spans a SECOND comment line (multi-line hint parse). + file_put_contents($snip . '/hello.php', <<<'PHP' +rmrf($this->moduleDir); + 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 key(string $file): string + { + return self::SLUG . '/' . $file; + } + + // ---- discovery + hint parsing -------------------------------------------------------------- + + #[Test] + public function all_discovers_the_fixture_snippets_and_parses_their_hints(): void + { + $all = Tiger_Code_Modules::all(); + + $helloKey = $this->key('hello'); + $this->assertArrayHasKey($helloKey, $all, 'the file was discovered under the module snippets dir'); + + $hello = $all[$helloKey]; + $this->assertSame($helloKey, $hello['key']); + $this->assertSame(self::SLUG, $hello['module']); + $this->assertSame('Hello', $hello['label']); + $this->assertSame('Strings', $hello['category']); + $this->assertSame('global', $hello['scope']); + // The description sat on a SECOND comment line — proving the multi-line header parse. + $this->assertSame('w5_hello(): a tiny greeter helper.', $hello['description']); + } + + #[Test] + public function get_returns_one_snippet_and_null_for_an_unknown_key(): void + { + $this->assertNotNull(Tiger_Code_Modules::get($this->key('admincron'))); + $this->assertNull(Tiger_Code_Modules::get(self::SLUG . '/does-not-exist')); + } + + // ---- body() + source() --------------------------------------------------------------------- + + #[Test] + public function body_normalizes_the_php_and_source_returns_raw_bytes(): void + { + $body = Tiger_Code_Modules::body($this->key('hello')); + // normalize() strips the opening tag but keeps the definition. + $this->assertStringNotContainsString('assertStringContainsString('function w5_hello', $body); + + $source = Tiger_Code_Modules::source($this->key('hello')); + $this->assertStringContainsString('assertStringContainsString('tiger:snippet', $source); + + // A vanished key yields '' from both. + $this->assertSame('', Tiger_Code_Modules::body(self::SLUG . '/missing')); + $this->assertSame('', Tiger_Code_Modules::source(self::SLUG . '/missing')); + } + + // ---- the active config set ----------------------------------------------------------------- + + #[Test] + public function set_active_toggles_the_config_set_and_is_active_reflects_it(): void + { + $key = $this->key('hello'); + + $this->assertFalse(Tiger_Code_Modules::isActive($key), 'nothing is active by default'); + $this->assertNotContains($key, Tiger_Code_Modules::activeKeys()); + + Tiger_Code_Modules::setActive($key, true); + $this->assertTrue(Tiger_Code_Modules::isActive($key)); + $this->assertContains($key, Tiger_Code_Modules::activeKeys()); + + Tiger_Code_Modules::setActive($key, false); + $this->assertFalse(Tiger_Code_Modules::isActive($key)); + $this->assertNotContains($key, Tiger_Code_Modules::activeKeys()); + } + + // ---- activeForLoad(): active + scope-matched only ------------------------------------------ + + #[Test] + public function active_for_load_returns_only_active_scope_matched_snippets(): void + { + $hello = $this->key('hello'); // scope=global + $admin = $this->key('admincron'); // scope=admin + + Tiger_Code_Modules::setActive($hello, true); + Tiger_Code_Modules::setActive($admin, true); + + $global = Tiger_Code_Modules::activeForLoad(Tiger_Model_Code::LOC_GLOBAL); + $this->assertArrayHasKey($hello, $global, 'the active global-scope snippet loads at global'); + $this->assertArrayNotHasKey($admin, $global, 'an admin-scope snippet never loads at global'); + + // At the admin location, the admin one loads and the global one does not. + $adminLoad = Tiger_Code_Modules::activeForLoad(Tiger_Model_Code::LOC_ADMIN); + $this->assertArrayHasKey($admin, $adminLoad); + $this->assertArrayNotHasKey($hello, $adminLoad); + } + + #[Test] + public function active_for_load_excludes_a_discovered_but_inactive_snippet(): void + { + // Discovered (all() sees it) but never activated → not in the load set. + $this->assertArrayHasKey($this->key('hello'), Tiger_Code_Modules::all()); + $this->assertSame([], Tiger_Code_Modules::activeForLoad(Tiger_Model_Code::LOC_GLOBAL)); + } +} diff --git a/tests/Integration/Code/RuntimeExtraTest.php b/tests/Integration/Code/RuntimeExtraTest.php new file mode 100644 index 0000000..282b326 --- /dev/null +++ b/tests/Integration/Code/RuntimeExtraTest.php @@ -0,0 +1,250 @@ +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 + { + // All this suite's locations share the 'codew' prefix (letters-only), so glob them away. + foreach (glob($this->cacheDir . '/codew*.php') ?: [] as $f) { @unlink($f); } + foreach (glob($this->cacheDir . '/inject.codew*.php') ?: [] as $f) { @unlink($f); } + foreach (glob($this->publicDir . '/codew*') ?: [] as $f) { @unlink($f); } + @unlink($this->cacheDir . '/DISABLED'); + Tiger_Log::reset(); + + 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(); + } + + /** + * Seed the tiger.code config node boot()/version()/enabled() read from the registry. A null log + * sink rides along so the self-heal's WARN doesn't trip the strict output check. + */ + private function seedCodeConfig(int $version, int $enabled = 1): void + { + Zend_Registry::set('Zend_Config', new Zend_Config([ + 'tiger' => [ + 'code' => ['version' => $version, 'enabled' => $enabled], + 'log' => ['writer' => 'null'], + ], + ])); + Tiger_Log::reset(); // drop any cached logger so it re-reads the null writer + } + + private function insertPhp(string $location, string $name, string $code, string $orgId = ''): string + { + return (new Tiger_Model_Code())->insert([ + 'org_id' => $orgId, + 'name' => $name, + 'language' => Tiger_Model_Code::LANG_PHP, + 'code' => $code, + 'run_location' => $location, + 'priority' => 100, + 'active' => 1, + 'status' => Tiger_Model_Code::STATUS_ACTIVE, + ]); + } + + private function insertClient(string $location, string $name, string $language, string $code): string + { + return (new Tiger_Model_Code())->insert([ + 'org_id' => '', + 'name' => $name, + 'language' => $language, + 'code' => $code, + 'run_location' => $location, + 'auto_insert' => Tiger_Model_Code::AUTO_HEAD, + 'priority' => 100, + 'active' => 1, + 'status' => Tiger_Model_Code::STATUS_ACTIVE, + ]); + } + + // ---- boot(): the happy per-request load ---------------------------------------------------- + + #[Test] + public function boot_compiles_and_includes_the_active_bundle(): void + { + $loc = 'codewboot'; + $this->insertPhp($loc, 'greeter', "if (!function_exists('w5_boot_fn')) { function w5_boot_fn() { return 7; } }"); + $this->seedCodeConfig(1); // version 1 → boot compiles v1 if missing, then includes it + + Tiger_Code_Runtime::boot($loc); + + $this->assertTrue(function_exists('w5_boot_fn'), 'the active snippet was compiled + loaded'); + $this->assertSame(7, w5_boot_fn()); + } + + #[Test] + public function boot_is_a_no_op_when_the_version_token_is_zero(): void + { + $loc = 'codewzero'; + $this->insertPhp($loc, 'never', "if (!function_exists('w5_never_fn')) { function w5_never_fn() {} }"); + $this->seedCodeConfig(0); // version 0 = nothing ever activated → boot returns early + + Tiger_Code_Runtime::boot($loc); + $this->assertFalse(function_exists('w5_never_fn'), 'version 0 short-circuits boot (no compile)'); + } + + // ---- boot(): self-heal on a catchable load error ------------------------------------------- + + #[Test] + public function boot_auto_deactivates_a_snippet_that_throws_while_loading(): void + { + // Calling an undefined function at load throws a CATCHABLE Error in PHP 8 → boot() catches it, + // marks the running snippet errored, and rebuilds so the next request is clean. + $loc = 'codewheal'; + $id = $this->insertPhp($loc, 'self-destruct', 'w5_undefined_function_zzz();'); + $this->seedCodeConfig(1); + + Tiger_Code_Runtime::boot($loc); + + $row = (new Tiger_Model_Code())->findById($id); + $this->assertNotNull($row); + $this->assertSame(Tiger_Model_Code::STATUS_ERROR, $row['status'], 'the failing snippet was flagged errored'); + $this->assertSame(0, (int) $row['active'], 'and deactivated so it never runs again'); + $this->assertNotEmpty($row['last_error'], 'the killing error was recorded'); + } + + // ---- rebuild(): the version token ---------------------------------------------------------- + + #[Test] + public function rebuild_bumps_the_version_token_monotonically(): void + { + $loc = 'codewrebuild'; + $this->insertPhp($loc, 'r', "if (!function_exists('w5_rebuild_fn')) { function w5_rebuild_fn() {} }"); + + $v1 = Tiger_Code_Runtime::rebuild($loc); + $v2 = Tiger_Code_Runtime::rebuild($loc); + + $this->assertGreaterThan(0, $v1); + $this->assertSame($v1 + 1, $v2, 'each rebuild increments the version token'); + // The compiled bundle for the newest version is on disk (filename is letters-only). + $this->assertFileExists($this->cacheDir . '/' . $loc . '.' . $v2 . '.php'); + } + + // ---- enabled() / version(): the kill-switch + token ---------------------------------------- + + #[Test] + public function enabled_defaults_true_and_the_config_flag_and_disabled_file_both_turn_it_off(): void + { + @unlink($this->cacheDir . '/DISABLED'); + + // No config node at all → default enabled. + Zend_Registry::set('Zend_Config', new Zend_Config([])); + $this->assertTrue(Tiger_Code_Runtime::enabled()); + + // config enabled=0 → disabled. + $this->seedCodeConfig(3, 0); + $this->assertFalse(Tiger_Code_Runtime::enabled()); + + // Even with enabled=1, the DISABLED file wins (fastest recovery). + $this->seedCodeConfig(3, 1); + $this->assertTrue(Tiger_Code_Runtime::enabled()); + if (!is_dir($this->cacheDir)) { @mkdir($this->cacheDir, 0775, true); } + file_put_contents($this->cacheDir . '/DISABLED', '1'); + $this->assertFalse(Tiger_Code_Runtime::enabled(), 'the DISABLED file hard-stops execution'); + } + + #[Test] + public function version_reads_the_config_token(): void + { + Zend_Registry::set('Zend_Config', new Zend_Config([])); + $this->assertSame(0, Tiger_Code_Runtime::version(), 'no config node → 0'); + + $this->seedCodeConfig(42); + $this->assertSame(42, Tiger_Code_Runtime::version()); + } + + // ---- the client tier: compileClient() + injectManifest() ----------------------------------- + + #[Test] + public function compile_client_writes_public_assets_and_the_inject_manifest(): void + { + $loc = 'codewclient'; + $this->insertClient($loc, 'brand-css', Tiger_Model_Code::LANG_CSS, 'body { color: rebeccapurple; }'); + $this->insertClient($loc, 'tracker-js', Tiger_Model_Code::LANG_JS, 'window.__w5 = 1;'); + + $version = 5; + Tiger_Code_Runtime::compileClient($loc, $version); + + // Versioned public assets exist for both tiers. + $this->assertFileExists($this->publicDir . '/' . $loc . '.' . $version . '.css'); + $this->assertFileExists($this->publicDir . '/' . $loc . '.' . $version . '.head.js'); + + // injectManifest() returns the head items pointing at those assets. + $manifest = Tiger_Code_Runtime::injectManifest($version, $loc); + $this->assertArrayHasKey('head', $manifest); + $types = array_column($manifest['head'], 'type'); + $this->assertContains('css_asset', $types); + $this->assertContains('js_asset', $types); + } + + #[Test] + public function inject_manifest_compiles_if_missing(): void + { + $loc = 'codewinject'; + $this->insertClient($loc, 'inline-html', Tiger_Model_Code::LANG_HTML, ''); + + // No manifest on disk yet for this version → injectManifest() compiles it on demand. + $version = 9; + $mf = $this->cacheDir . '/inject.' . $loc . '.' . $version . '.php'; + @unlink($mf); + + $manifest = Tiger_Code_Runtime::injectManifest($version, $loc); + + $this->assertFileExists($mf, 'the manifest was compiled on demand'); + $inlineTypes = array_column($manifest['head'], 'type'); + $this->assertContains('html', $inlineTypes, 'the inline html item is in the head manifest'); + } +} diff --git a/tests/Integration/Controller/ControllerBaseTest.php b/tests/Integration/Controller/ControllerBaseTest.php new file mode 100644 index 0000000..f7fd524 --- /dev/null +++ b/tests/Integration/Controller/ControllerBaseTest.php @@ -0,0 +1,137 @@ +priorUnitTestMode = Zend_Session::$_unitTestEnabled; + Zend_Session::$_unitTestEnabled = true; // array-backed session so the FlashMessenger runs under CLI + $_SESSION = []; + + Zend_Registry::set('Zend_View', new Zend_View()); + // The ViewRenderer resolves a module's view dir from the front controller; register the + // default-namespace controller dir (tiger-core's core/controllers) so touching viewRenderer + // in _json() can locate module "default" under CLI. + Zend_Controller_Front::getInstance()->addControllerDirectory(TIGER_CORE_PATH . '/core/controllers', 'default'); + Zend_Layout::startMvc(); + } + + protected function tearDown(): void + { + Zend_Layout::resetMvcInstance(); + Zend_Controller_Front::getInstance()->resetInstance(); + $_SESSION = []; + Zend_Session::$_unitTestEnabled = $this->priorUnitTestMode; + $reg = Zend_Registry::getInstance(); + foreach (['Zend_View', 'Zend_Config', 'Zend_Translate'] as $k) { + if ($reg->offsetExists($k)) { $reg->offsetUnset($k); } + } + parent::tearDown(); + } + + /** A concrete Tiger_Controller_Action exposing the protected handles + _json. */ + private function baseController(Zend_Controller_Request_Http $req, Zend_Controller_Response_Http $res): Tiger_Controller_Action + { + return new class ($req, $res) extends Tiger_Controller_Action { + public function config() { return $this->_config; } + public function translate() { return $this->_translate; } + public function flash() { return $this->_flash; } + public function pubJson($data, $status = 200) { $this->_json($data, $status); } + }; + } + + #[Test] + public function init_wires_the_config_and_translate_handles_from_the_registry(): void + { + Zend_Registry::set('Zend_Config', new Zend_Config(['app' => ['name' => 'Tiger']])); + Zend_Registry::set('Zend_Translate', new Zend_Translate(['adapter' => 'array', 'content' => ['x' => 'y'], 'locale' => 'en'])); + + $ctrl = $this->baseController(new Zend_Controller_Request_Http(), new Zend_Controller_Response_Http()); + + $this->assertInstanceOf(Zend_Config::class, $ctrl->config()); + $this->assertSame('Tiger', $ctrl->config()->app->name); + $this->assertInstanceOf(Zend_Translate::class, $ctrl->translate()); + $this->assertNotNull($ctrl->flash(), 'the flash messenger handle is wired'); + } + + #[Test] + public function init_leaves_the_handles_null_when_nothing_is_registered(): void + { + // No Zend_Config / Zend_Translate in the registry — the base guards each with isRegistered(). + $ctrl = $this->baseController(new Zend_Controller_Request_Http(), new Zend_Controller_Response_Http()); + + $this->assertNull($ctrl->config()); + $this->assertNull($ctrl->translate()); + } + + #[Test] + public function json_sets_the_content_type_status_and_encoded_body(): void + { + $res = new Zend_Controller_Response_Http(); + $ctrl = $this->baseController(new Zend_Controller_Request_Http(), $res); + $ctrl->getHelper('viewRenderer'); // a real dispatch initializes this before the action + + $ctrl->pubJson(['ok' => true, 'name' => 'Thundarr'], 201); + + $this->assertSame(201, $res->getHttpResponseCode()); + $this->assertSame('{"ok":true,"name":"Thundarr"}', $res->getBody()); + $headers = $res->getHeaders(); + $ct = ''; + foreach ($headers as $h) { if (strtolower($h['name']) === 'content-type') { $ct = $h['value']; } } + $this->assertStringContainsString('application/json', $ct); + $this->assertFalse($ctrl->getHelper('layout')->isEnabled(), 'the layout is disabled for a JSON body'); + } + + #[Test] + public function json_does_not_escape_unicode_or_slashes(): void + { + $res = new Zend_Controller_Response_Http(); + $ctrl = $this->baseController(new Zend_Controller_Request_Http(), $res); + $ctrl->getHelper('viewRenderer'); + + $ctrl->pubJson(['path' => '/a/b', 'name' => 'café']); + $this->assertSame('{"path":"/a/b","name":"café"}', $res->getBody()); + } + + #[Test] + public function the_admin_base_sets_the_admin_layout_in_init(): void + { + $ctrl = new class (new Zend_Controller_Request_Http(), new Zend_Controller_Response_Http()) extends Tiger_Controller_Admin_Action {}; + $this->assertSame('admin', $ctrl->getHelper('layout')->getLayout()); + } +} diff --git a/tests/Integration/Controller/LocalePrefixPluginTest.php b/tests/Integration/Controller/LocalePrefixPluginTest.php new file mode 100644 index 0000000..afa2d94 --- /dev/null +++ b/tests/Integration/Controller/LocalePrefixPluginTest.php @@ -0,0 +1,165 @@ +priorCookie = $_COOKIE; + $this->priorServer = $_SERVER; + unset($_COOKIE['locale'], $_SERVER['HTTP_ACCEPT_LANGUAGE']); + } + + protected function tearDown(): void + { + $_COOKIE = $this->priorCookie; + $_SERVER = $this->priorServer; + parent::tearDown(); + } + + private function plugin(): Tiger_Controller_Plugin_LocalePrefix + { + return new Tiger_Controller_Plugin_LocalePrefix(['en', 'es'], 'en'); + } + + private function http(string $pathInfo): Zend_Controller_Request_Http + { + $r = new Zend_Controller_Request_Http(); + $r->setPathInfo($pathInfo); + return $r; + } + + #[Test] + public function a_non_http_request_is_ignored(): void + { + $req = new Zend_Controller_Request_Simple(); + $this->plugin()->routeStartup($req); + $this->assertTrue(true, 'no throw on a non-HTTP request'); + } + + #[Test] + public function a_supported_locale_prefix_is_stripped_from_the_path(): void + { + $req = $this->http('/es/pricing'); + $this->plugin()->routeStartup($req); + + $this->assertSame('/pricing', $req->getPathInfo(), 'the /es prefix is stripped so routes match'); + $this->assertSame('es', $_COOKIE['locale'], 'the URL locale is resolved + persisted'); + } + + #[Test] + public function a_bare_locale_prefix_becomes_the_root_path(): void + { + $req = $this->http('/es'); + $this->plugin()->routeStartup($req); + + $this->assertSame('/', $req->getPathInfo()); + $this->assertSame('es', $_COOKIE['locale']); + } + + #[Test] + public function an_unsupported_two_letter_segment_is_not_treated_as_a_locale(): void + { + // "no" (a content slug) is NOT in the supported set, so the path is left intact. + $req = $this->http('/no/thanks'); + $this->plugin()->routeStartup($req); + + $this->assertSame('/no/thanks', $req->getPathInfo(), 'a non-locale 2-letter head is preserved'); + } + + #[Test] + public function the_cookie_resolves_the_language_when_there_is_no_prefix(): void + { + $_COOKIE['locale'] = 'es'; + $req = $this->http('/pricing'); + $this->plugin()->routeStartup($req); + + $this->assertSame('/pricing', $req->getPathInfo()); + $this->assertSame('es', $_COOKIE['locale']); + } + + #[Test] + public function the_browser_accept_language_resolves_when_no_prefix_or_cookie(): void + { + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'es-ES,es;q=0.9,en;q=0.8'; + $req = $this->http('/pricing'); + $this->plugin()->routeStartup($req); + + $this->assertSame('es', $_COOKIE['locale'], 'the first supported browser language wins'); + } + + #[Test] + public function it_falls_back_to_the_configured_default(): void + { + // No prefix, no cookie, no (supported) browser header => the default 'en'. + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'fr-FR,fr;q=0.9'; + $req = $this->http('/pricing'); + $this->plugin()->routeStartup($req); + + $this->assertSame('en', $_COOKIE['locale']); + } + + #[Test] + public function a_signed_in_users_stored_locale_outranks_the_cookie(): void + { + // The account choice follows the person across devices — it beats the device cookie. + $userId = (new Tiger_Model_User())->insert(['email' => 'loc@user.test', 'status' => 'active', 'locale' => 'es']); + $this->login($userId, 'org-test', 'user'); + $_COOKIE['locale'] = 'en'; // a stale device cookie that must lose to the account locale + + $req = $this->http('/pricing'); + $this->plugin()->routeStartup($req); + + $this->assertSame('es', $_COOKIE['locale'], 'user.locale wins over the cookie'); + } + + #[Test] + public function an_explicit_url_prefix_still_wins_over_the_users_locale(): void + { + $userId = (new Tiger_Model_User())->insert(['email' => 'loc2@user.test', 'status' => 'active', 'locale' => 'es']); + $this->login($userId, 'org-test', 'user'); + + $req = $this->http('/en/pricing'); // explicit for THIS navigation + $this->plugin()->routeStartup($req); + + $this->assertSame('en', $_COOKIE['locale'], 'the URL prefix is the top of the precedence chain'); + } + + #[Test] + public function it_defines_the_lang_and_supported_langs_constants(): void + { + $req = $this->http('/pricing'); + $this->plugin()->routeStartup($req); + + // define() fires once per process, so assert presence (not a specific value that a prior test may own). + $this->assertTrue(defined('LANG')); + $this->assertTrue(defined('SUPPORTED_LANGS')); + } +} diff --git a/tests/Integration/Controller/PageDispatchPluginTest.php b/tests/Integration/Controller/PageDispatchPluginTest.php new file mode 100644 index 0000000..e33f0f9 --- /dev/null +++ b/tests/Integration/Controller/PageDispatchPluginTest.php @@ -0,0 +1,142 @@ +locale = defined('LANG') ? LANG : 'en'; + Tiger_Model_Org::setSiteOrgId(''); // resolve public content at global scope ('') + Zend_Controller_Front::getInstance()->resetInstance(); + } + + protected function tearDown(): void + { + // Don't leak the forced site-org into other integration tests — reset the memo to null. + (new ReflectionProperty(Tiger_Model_Org::class, '_siteOrgId'))->setValue(null, null); + Zend_Controller_Front::getInstance()->resetInstance(); + parent::tearDown(); + } + + private function plugin(): Tiger_Controller_Plugin_PageDispatch + { + return new Tiger_Controller_Plugin_PageDispatch(); + } + + private function http(string $pathInfo): Zend_Controller_Request_Http + { + $r = new Zend_Controller_Request_Http(); + $r->setPathInfo($pathInfo); + return $r; + } + + private function seedPage(string $slug): string + { + return (new Tiger_Model_Page())->insert([ + 'org_id' => '', + 'type' => Tiger_Model_Page::TYPE_PAGE, + 'slug' => $slug, + 'locale' => $this->locale, + 'title' => 'Seeded ' . $slug, + 'body' => '

hi

', + 'format' => 'html', + 'status' => Tiger_Model_Page::STATUS_PUBLISHED, + ]); + } + + #[Test] + public function a_non_http_request_is_ignored(): void + { + $req = new Zend_Controller_Request_Simple(); + $req->setControllerName('foo'); + $this->plugin()->routeShutdown($req); + $this->assertSame('foo', $req->getControllerName()); + } + + #[Test] + public function the_root_path_is_left_to_the_index_controller(): void + { + $this->seedPage('anything'); + $req = $this->http('/'); + $this->plugin()->routeShutdown($req); + $this->assertNull($req->getControllerName(), 'the root belongs to IndexController'); + } + + #[Test] + public function a_published_page_claims_its_slug_and_routes_to_page_view(): void + { + $pageId = $this->seedPage('about-us'); + $req = $this->http('/about-us'); + + $this->plugin()->routeShutdown($req); + + $this->assertSame('default', $req->getModuleName()); + $this->assertSame('page', $req->getControllerName()); + $this->assertSame('view', $req->getActionName()); + $this->assertSame($pageId, $req->getParam('cms_page_id')); + } + + #[Test] + public function a_reserved_first_segment_is_never_claimed_by_a_page(): void + { + // Even if a page somehow held the slug, a core system-controller prefix is off-limits. + $this->seedPage('admin'); + $req = $this->http('/admin/settings'); + + $this->plugin()->routeShutdown($req); + $this->assertNotSame('page', $req->getControllerName(), '/admin is reserved'); + } + + #[Test] + public function an_unclaimed_slug_with_no_redirect_is_left_untouched(): void + { + // No page, no page_redirect, nothing dispatchable => the request is left for a clean 404. + $req = $this->http('/no-such-page'); + $this->plugin()->routeShutdown($req); + $this->assertNotSame('page', $req->getControllerName()); + } + + #[Test] + public function a_draft_page_does_not_claim_the_slug(): void + { + (new Tiger_Model_Page())->insert([ + 'org_id' => '', 'type' => Tiger_Model_Page::TYPE_PAGE, 'slug' => 'draft-page', + 'locale' => $this->locale, 'title' => 'Draft', 'body' => 'x', 'format' => 'html', + 'status' => 'draft', + ]); + $req = $this->http('/draft-page'); + + $this->plugin()->routeShutdown($req); + $this->assertNotSame('page', $req->getControllerName(), 'only published pages answer'); + } +} diff --git a/tests/Integration/Controller/ScheduleTickPluginTest.php b/tests/Integration/Controller/ScheduleTickPluginTest.php new file mode 100644 index 0000000..401b5a3 --- /dev/null +++ b/tests/Integration/Controller/ScheduleTickPluginTest.php @@ -0,0 +1,113 @@ +stateDir = (defined('APPLICATION_ROOT') ? APPLICATION_ROOT : getcwd()) . '/var/schedule'; + @mkdir($this->stateDir, 0775, true); + $this->tickAt = $this->stateDir . '/tick.at'; + $this->cronAt = $this->stateDir . '/cron.at'; + $this->clearState(); + + $this->hadConfig = Zend_Registry::isRegistered('Zend_Config'); + $this->priorConfig = $this->hadConfig ? Zend_Registry::get('Zend_Config') : null; + } + + protected function tearDown(): void + { + $this->clearState(); + if ($this->hadConfig) { + Zend_Registry::set('Zend_Config', $this->priorConfig); + } elseif (Zend_Registry::isRegistered('Zend_Config')) { + Zend_Registry::getInstance()->offsetUnset('Zend_Config'); + } + parent::tearDown(); + } + + private function clearState(): void + { + foreach ([$this->tickAt, $this->cronAt] as $f) { if (is_file($f)) { @unlink($f); } } + } + + private function setPseudoCron(string $flag): void + { + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['schedule' => ['pseudo_cron' => $flag]]])); + } + + private function tick(): void + { + (new Tiger_Controller_Plugin_ScheduleTick())->routeShutdown(new Zend_Controller_Request_Http()); + } + + #[Test] + public function it_yields_when_pseudo_cron_is_disabled_by_config(): void + { + $this->setPseudoCron('0'); + $this->tick(); + $this->assertFileDoesNotExist($this->tickAt, 'disabled => no tick claimed'); + } + + #[Test] + public function it_yields_when_a_real_cron_ticked_recently(): void + { + $this->setPseudoCron('1'); + touch($this->cronAt); // a real cron heartbeat within the last ~2 minutes + $this->tick(); + $this->assertFileDoesNotExist($this->tickAt, 'a recent real cron makes pseudo-cron stand down'); + } + + #[Test] + public function it_throttles_when_a_tick_is_not_yet_due(): void + { + $this->setPseudoCron('1'); + // A tick claimed 10s ago is still within the ~1-minute throttle window. + touch($this->tickAt, time() - 10); + $before = filemtime($this->tickAt); + + $this->tick(); + + clearstatcache(); + $this->assertSame($before, filemtime($this->tickAt), 'the throttle short-circuits before re-claiming'); + } + + #[Test] + public function it_claims_the_tick_when_all_gates_pass(): void + { + $this->setPseudoCron('1'); + // No cron.at (no real cron), no tick.at (a tick is due) => the plugin claims this minute. + $this->tick(); + $this->assertFileExists($this->tickAt, 'the happy path stamps the tick-claim file'); + } +} diff --git a/tests/Integration/Model/AdminFindersTest.php b/tests/Integration/Model/AdminFindersTest.php new file mode 100644 index 0000000..11bcc0d --- /dev/null +++ b/tests/Integration/Model/AdminFindersTest.php @@ -0,0 +1,155 @@ +insert(['name' => 'Root', 'slug' => 'root-' . bin2hex(random_bytes(4))]); + $a = $org->insert(['name' => 'Dept A', 'slug' => 'a-' . bin2hex(random_bytes(4)), 'parent_org_id' => $root]); + $b = $org->insert(['name' => 'Dept B', 'slug' => 'b-' . bin2hex(random_bytes(4)), 'parent_org_id' => $root]); + $org->insert(['name' => 'Unrelated', 'slug' => 'u-' . bin2hex(random_bytes(4))]); + + $kids = $org->children($root); + $ids = []; + foreach ($kids as $k) { $ids[] = $k->org_id; } + sort($ids); + $expected = [$a, $b]; sort($expected); + $this->assertSame($expected, $ids, 'only the direct children of the root are returned'); + } + + #[Test] + public function org_datatable_reports_a_live_member_count_and_parent_name(): void + { + $org = new Tiger_Model_Org(); + $orgUser = new Tiger_Model_OrgUser(); + $user = new Tiger_Model_User(); + + $parent = $org->insert(['name' => 'Acme Parent', 'slug' => 'acme-' . bin2hex(random_bytes(4))]); + $child = $org->insert(['name' => 'Acme Child', 'slug' => 'acmec-' . bin2hex(random_bytes(4)), 'parent_org_id' => $parent]); + + // Two members on the child org. + $u1 = $user->insert(['email' => 'm1-' . bin2hex(random_bytes(6)) . '@example.test']); + $u2 = $user->insert(['email' => 'm2-' . bin2hex(random_bytes(6)) . '@example.test']); + $orgUser->insert(['org_id' => $child, 'user_id' => $u1, 'role' => 'admin']); + $orgUser->insert(['org_id' => $child, 'user_id' => $u2, 'role' => 'member']); + + $result = $org->datatable(['search' => 'Acme Child', 'limit' => 25]); + $this->assertSame(1, $result['filtered']); + $row = $result['rows'][0]; + $this->assertSame('Acme Child', $row['name']); + $this->assertSame('Acme Parent', $row['parent_name'], 'the parent org name is joined in'); + $this->assertSame(2, (int) $row['member_count'], 'the live member count comes from org_user'); + } + + #[Test] + public function org_datatable_filters_by_status(): void + { + $org = new Tiger_Model_Org(); + $org->insert(['name' => 'Zeta Active', 'slug' => 'za-' . bin2hex(random_bytes(4)), 'status' => 'active']); + $org->insert(['name' => 'Zeta Suspended', 'slug' => 'zs-' . bin2hex(random_bytes(4)), 'status' => 'suspended']); + + $suspended = $org->datatable(['search' => 'Zeta', 'status' => 'suspended', 'limit' => 25]); + $this->assertSame(1, $suspended['filtered']); + $this->assertSame('Zeta Suspended', $suspended['rows'][0]['name']); + } + + #[Test] + public function set_site_org_id_overrides_the_cached_resolution(): void + { + // Snapshot the private static so we can restore it EXACTLY (setSiteOrgId('') would cache '' + // as a resolved value, poisoning the lazy founding-org resolution for sibling test files). + $ref = new \ReflectionProperty(Tiger_Model_Org::class, '_siteOrgId'); + $prior = $ref->getValue(); + try { + $forced = Tiger_Uuid::v7(); + Tiger_Model_Org::setSiteOrgId($forced); + $this->assertSame($forced, Tiger_Model_Org::siteOrgId(), 'a multi-site override wins over the founding-org heuristic'); + } finally { + $ref->setValue(null, $prior); // restore the exact prior state (usually null = unresolved) + } + } + + // ---- User -------------------------------------------------------------- + + #[Test] + public function preferred_locale_returns_a_supported_locale_or_null(): void + { + $user = new Tiger_Model_User(); + $es = $user->insert(['email' => 'loc-' . bin2hex(random_bytes(6)) . '@example.test', 'locale' => 'es']); + $none = $user->insert(['email' => 'loc-' . bin2hex(random_bytes(6)) . '@example.test']); + $xx = $user->insert(['email' => 'loc-' . bin2hex(random_bytes(6)) . '@example.test', 'locale' => 'xx']); + + $this->assertSame('es', $user->preferredLocale($es, ['en', 'es']), 'a supported stored locale resolves'); + $this->assertNull($user->preferredLocale($none, ['en', 'es']), 'no stored locale → null'); + $this->assertNull($user->preferredLocale($xx, ['en', 'es']), 'an unsupported locale → null'); + $this->assertNull($user->preferredLocale(Tiger_Uuid::v7(), ['en']), 'an unknown user → null'); + } + + #[Test] + public function user_datatable_summarizes_membership_and_searches(): void + { + $user = new Tiger_Model_User(); + $orgUser = new Tiger_Model_OrgUser(); + $org = new Tiger_Model_Org(); + + // org_user carries an FK to org, so the memberships need real org rows. + $orgA = $org->insert(['name' => 'GridOrg A', 'slug' => 'ga-' . bin2hex(random_bytes(4))]); + $orgB = $org->insert(['name' => 'GridOrg B', 'slug' => 'gb-' . bin2hex(random_bytes(4))]); + $u = $user->insert(['email' => 'grid-' . bin2hex(random_bytes(6)) . '@example.test', 'username' => 'griduser' . bin2hex(random_bytes(3))]); + $orgUser->insert(['org_id' => $orgA, 'user_id' => $u, 'role' => 'admin']); + $orgUser->insert(['org_id' => $orgB, 'user_id' => $u, 'role' => 'member']); + + $result = $user->datatable(['search' => $user->findById($u)->email, 'limit' => 25]); + $this->assertSame(1, $result['filtered']); + $row = $result['rows'][0]; + $this->assertSame(2, (int) $row['org_count'], 'the user belongs to two orgs'); + $this->assertStringContainsString('admin', (string) $row['role_names'], 'the distinct roles are concatenated'); + $this->assertStringContainsString('member', (string) $row['role_names']); + } + + // ---- Module ------------------------------------------------------------ + + #[Test] + public function module_by_slug_and_by_slug_map(): void + { + $module = new Tiger_Model_Module(); + $module->install('blog-' . bin2hex(random_bytes(4)), ['name' => 'Blog', 'version' => '1.0.0']); + $slug = 'shop-' . bin2hex(random_bytes(4)); + $module->install($slug, ['name' => 'Shop', 'version' => '2.0.0']); // install() returns the id, not the slug + + $row = $module->bySlug($slug); + $this->assertNotNull($row, 'bySlug finds the row'); + $this->assertSame('Shop', $row->name); + $this->assertNull($module->bySlug('does-not-exist'), 'a missing slug returns null'); + + $map = $module->bySlugMap(); + $this->assertArrayHasKey($slug, $map, 'bySlugMap is keyed by slug'); + $this->assertSame('Shop', $map[$slug]->name); + } +} diff --git a/tests/Integration/Model/AgentModelsTest.php b/tests/Integration/Model/AgentModelsTest.php new file mode 100644 index 0000000..8382026 --- /dev/null +++ b/tests/Integration/Model/AgentModelsTest.php @@ -0,0 +1,166 @@ +start($orgId, $me, 'First thread', 'anthropic', 'claude-opus'); + $b = $conv->start($orgId, $me, str_repeat('x', 300), 'openai', 'gpt-5'); // title clamps to 191 + $conv->start($orgId, $other, 'Not mine', 'anthropic', 'claude'); + + $mine = $conv->recentForUser($orgId, $me, 20); + $this->assertCount(2, $mine, 'recentForUser is scoped to the owner'); + $ids = array_column($mine, 'conversation_id'); + $this->assertContains($a, $ids); + $this->assertContains($b, $ids); + + $titleB = $conv->findById($b)->title; + $this->assertSame(191, mb_strlen($titleB), 'the title is clamped to 191 chars'); + + $this->assertNotNull($conv->ownedById($a, $me), 'the owner can load their thread'); + $this->assertNull($conv->ownedById($a, $other), 'a foreign user cannot load it'); + } + + #[Test] + public function recent_for_user_without_an_org_matches_across_orgs(): void + { + $conv = new Tiger_Model_AgentConversation(); + $me = Tiger_Uuid::v7(); + $conv->start(Tiger_Uuid::v7(), $me, 'A', 'anthropic', 'm'); + $conv->start(Tiger_Uuid::v7(), $me, 'B', 'anthropic', 'm'); + + // A blank org means "don't filter by org" — both threads come back. + $this->assertCount(2, $conv->recentForUser('', $me, 20)); + } + + #[Test] + public function messages_append_and_transcript_reads_oldest_first(): void + { + $conv = new Tiger_Model_AgentConversation(); + $msg = new Tiger_Model_AgentMessage(); + $me = Tiger_Uuid::v7(); + $cid = $conv->start(Tiger_Uuid::v7(), $me, 'T', 'anthropic', 'claude'); + + $msg->append($cid, Tiger_Model_AgentMessage::ROLE_USER, 'hello'); + $runId = Tiger_Uuid::v7(); + $asst = $msg->append($cid, Tiger_Model_AgentMessage::ROLE_ASSISTANT, 'hi there', ['actions' => [['type' => 'navigate']]], $runId); + + $rows = $msg->transcript($cid, 100); + $this->assertCount(2, $rows); + $this->assertSame('hello', $rows[0]['content'], 'oldest message first'); + $this->assertSame('hi there', $rows[1]['content']); + $this->assertSame($runId, $rows[1]['run_id'], 'the producing run id rides along'); + + // The structured sidecar round-trips as JSON. + $meta = json_decode((string) $msg->findById($asst)->meta, true); + $this->assertSame('navigate', $meta['actions'][0]['type']); + } + + #[Test] + public function a_bare_user_message_has_a_null_run_id_and_null_meta(): void + { + $conv = new Tiger_Model_AgentConversation(); + $msg = new Tiger_Model_AgentMessage(); + $cid = $conv->start('', Tiger_Uuid::v7(), 'T', 'anthropic', 'claude'); + + $id = $msg->append($cid, Tiger_Model_AgentMessage::ROLE_USER, 'just asking'); + $row = $msg->findById($id); + $this->assertNull($row->run_id, 'a bare user message stores no run id'); + $this->assertNull($row->meta, 'no meta means a NULL column, not "null"'); + } + + #[Test] + public function transcript_tail_limit_returns_the_newest_but_still_oldest_first(): void + { + $conv = new Tiger_Model_AgentConversation(); + $msg = new Tiger_Model_AgentMessage(); + $cid = $conv->start('', Tiger_Uuid::v7(), 'T', 'anthropic', 'claude'); + + // Back-date each so created_at strictly increases (sub-second inserts would tie otherwise). + $t = time() - 40; + foreach (['m1', 'm2', 'm3', 'm4'] as $i => $c) { + $id = $msg->append($cid, Tiger_Model_AgentMessage::ROLE_USER, $c); + $this->db->update('agent_message', ['created_at' => date('Y-m-d H:i:s', $t + $i * 10)], $this->db->quoteInto('message_id = ?', $id)); + } + $tail = $msg->transcript($cid, 2); + $this->assertSame(['m3', 'm4'], array_column($tail, 'content'), 'the newest 2, re-ordered oldest-first'); + } + + #[Test] + public function a_run_opens_ok_then_finish_records_the_ledger_usage_and_status(): void + { + $run = new Tiger_Model_AgentRun(); + $me = Tiger_Uuid::v7(); + $cid = Tiger_Uuid::v7(); + + $id = $run->open($cid, $me); + $row = $run->findById($id); + $this->assertSame(Tiger_Model_AgentRun::STATUS_OK, $row->status, 'a run opens ok'); + $this->assertSame(1, (int) $row->steps); + + $actions = [['type' => 'page.create', 'state' => 'proposed']]; + $run->finish($id, Tiger_Model_AgentRun::STATUS_BLOCKED, $actions, ['input' => 120, 'output' => 45], 'needs approval'); + + $done = $run->findById($id); + $this->assertSame(Tiger_Model_AgentRun::STATUS_BLOCKED, $done->status); + $this->assertSame(120, (int) $done->input_tokens); + $this->assertSame(45, (int) $done->output_tokens); + $this->assertSame('needs approval', $done->error); + $this->assertSame('page.create', json_decode((string) $done->actions, true)[0]['type']); + } + + #[Test] + public function finish_with_no_usage_or_error_leaves_those_columns_null(): void + { + $run = new Tiger_Model_AgentRun(); + $id = $run->open(Tiger_Uuid::v7(), Tiger_Uuid::v7()); + $run->finish($id, Tiger_Model_AgentRun::STATUS_OK, [['type' => 'read', 'state' => 'done']]); + + $row = $run->findById($id); + $this->assertNull($row->input_tokens); + $this->assertNull($row->output_tokens); + $this->assertNull($row->error, 'an empty error string stores as NULL'); + } + + #[Test] + public function a_run_is_owner_scoped(): void + { + $run = new Tiger_Model_AgentRun(); + $me = Tiger_Uuid::v7(); + $id = $run->open(Tiger_Uuid::v7(), $me); + + $this->assertNotNull($run->ownedById($id, $me)); + $this->assertNull($run->ownedById($id, Tiger_Uuid::v7()), 'another user cannot load the run'); + } +} diff --git a/tests/Integration/Model/AuthChallengeExtraTest.php b/tests/Integration/Model/AuthChallengeExtraTest.php new file mode 100644 index 0000000..35b0095 --- /dev/null +++ b/tests/Integration/Model/AuthChallengeExtraTest.php @@ -0,0 +1,125 @@ + ['security' => ['pepper' => base64_encode(str_repeat("\x41", 32))]], + ], true)); + $this->challenge = new Tiger_Model_AuthChallenge(); + } + + private function makeUser(): string + { + return (new Tiger_Model_User())->insert(['email' => 'ac-' . bin2hex(random_bytes(8)) . '@example.test']); + } + + #[Test] + public function latest_active_returns_the_newest_usable_challenge_and_skips_dead_ones(): void + { + $user = $this->makeUser(); + + // An older challenge, then a newer one — latestActive returns the newest. + $old = $this->challenge->issue($user, 'email_verify', '111111', 600); + $this->db->update('auth_challenge', ['created_at' => date('Y-m-d H:i:s', time() - 120)], + $this->db->quoteInto('challenge_id = ?', $old)); + $new = $this->challenge->issue($user, 'email_verify', '222222', 600); + + $active = $this->challenge->latestActive($user, 'email_verify'); + $this->assertNotNull($active); + $this->assertSame($new, $active->challenge_id, 'the newest usable challenge wins'); + + // A different type has no active challenge. + $this->assertNull($this->challenge->latestActive($user, 'password_reset')); + } + + #[Test] + public function latest_active_ignores_expired_and_consumed_challenges(): void + { + $user = $this->makeUser(); + + // Expired. + $expired = $this->challenge->issue($user, 'sms_otp', '333333', 600); + $this->db->update('auth_challenge', ['expires_at' => date('Y-m-d H:i:s', time() - 60)], + $this->db->quoteInto('challenge_id = ?', $expired)); + $this->assertNull($this->challenge->latestActive($user, 'sms_otp'), 'an expired challenge is not active'); + + // Consumed. + $consumed = $this->challenge->issue($user, 'sms_otp', '444444', 600); + $this->challenge->redeem($consumed, '444444'); + $this->assertNull($this->challenge->latestActive($user, 'sms_otp'), 'a consumed challenge is not active'); + } + + #[Test] + public function invalidate_active_burns_outstanding_challenges_of_a_type(): void + { + $user = $this->makeUser(); + $this->challenge->issue($user, 'password_reset', '555555', 600); + $this->challenge->issue($user, 'password_reset', '666666', 600); + $this->challenge->issue($user, 'email_verify', '777777', 600); // a different type — untouched + + $n = $this->challenge->invalidateActive($user, 'password_reset'); + $this->assertSame(2, $n, 'both outstanding reset challenges were invalidated'); + + $this->assertNull($this->challenge->latestActive($user, 'password_reset'), 'no reset challenge remains active'); + $this->assertNotNull($this->challenge->latestActive($user, 'email_verify'), 'the other type is left alone'); + } + + #[Test] + public function count_recent_windows_the_send_rate_guard(): void + { + $user = $this->makeUser(); + + $this->challenge->issue($user, 'sms_otp', '000001', 600); + $this->challenge->issue($user, 'sms_otp', '000002', 600); + $old = $this->challenge->issue($user, 'sms_otp', '000003', 600); + // Back-date one outside the window. + $this->db->update('auth_challenge', ['created_at' => date('Y-m-d H:i:s', time() - 4000)], + $this->db->quoteInto('challenge_id = ?', $old)); + + $this->assertSame(2, $this->challenge->countRecent($user, 'sms_otp', 3600), 'only the two inside the window count'); + $this->assertSame(0, $this->challenge->countRecent($user, 'email_verify', 3600), 'a different type counts zero'); + } + + #[Test] + public function purge_expired_hard_deletes_only_expired_rows(): void + { + $user = $this->makeUser(); + $live = $this->challenge->issue($user, 'magic_link', '121212', 600); + $expired = $this->challenge->issue($user, 'magic_link', '343434', 600); + $this->db->update('auth_challenge', ['expires_at' => date('Y-m-d H:i:s', time() - 60)], + $this->db->quoteInto('challenge_id = ?', $expired)); + + $removed = $this->challenge->purgeExpired(); + $this->assertGreaterThanOrEqual(1, $removed, 'the expired challenge was purged'); + + // The live one is still present; the expired one is gone entirely (hard delete). + $this->assertNotNull($this->challenge->findById($live)); + $gone = (int) $this->db->fetchOne($this->db->quoteInto('SELECT COUNT(*) FROM auth_challenge WHERE challenge_id = ?', $expired)); + $this->assertSame(0, $gone, 'the expired row was hard-deleted'); + } +} diff --git a/tests/Integration/Model/CodeTest.php b/tests/Integration/Model/CodeTest.php new file mode 100644 index 0000000..3983bda --- /dev/null +++ b/tests/Integration/Model/CodeTest.php @@ -0,0 +1,201 @@ +` so the stored body is + * clean (a `code = new Tiger_Model_Code(); + } + + private function baseRow(array $overrides = []): array + { + return array_merge([ + 'org_id' => '', + 'name' => 'Snippet', + 'language' => Tiger_Model_Code::LANG_PHP, + 'code' => 'return 1;', + 'run_location' => Tiger_Model_Code::LOC_GLOBAL, + 'priority' => 10, + 'active' => 0, + 'status' => Tiger_Model_Code::STATUS_DRAFT, + ], $overrides); + } + + #[Test] + public function save_inserts_and_snapshots_a_first_version(): void + { + $id = $this->code->save($this->baseRow(['name' => 'Hello', 'code' => 'return 42;'])); + $this->assertNotSame('', $id); + + $row = $this->code->findById($id); + $this->assertSame('Hello', $row->name); + $this->assertSame('return 42;', $row->code); + + $versions = (new Tiger_Model_CodeVersion())->recentFor($id); + $this->assertCount(1, $versions, 'the insert snapshots version 1'); + $this->assertSame(1, (int) $versions->current()->version); + $this->assertSame('return 42;', $versions->current()->code); + } + + #[Test] + public function save_update_snapshots_a_new_version_each_time(): void + { + $id = $this->code->save($this->baseRow(['code' => 'return 1;'])); + $this->code->save(['name' => 'Snippet', 'language' => Tiger_Model_Code::LANG_PHP, 'code' => 'return 2;', + 'run_location' => Tiger_Model_Code::LOC_GLOBAL, 'priority' => 10, 'active' => 0, 'status' => Tiger_Model_Code::STATUS_DRAFT], $id); + + $this->assertSame('return 2;', $this->code->findById($id)->code, 'the row reflects the latest save'); + $this->assertCount(2, (new Tiger_Model_CodeVersion())->recentFor($id), 'each save adds a version'); + } + + #[Test] + public function normalize_strips_php_openers_bom_and_a_trailing_close_tag(): void + { + $this->assertSame("\nreturn 1;", $this->code->normalize("assertSame(' return 1;', $this->code->normalize('assertSame('echo $x;', $this->code->normalize('assertSame('return 1;', $this->code->normalize("return 1;?>\n")); + $this->assertSame(' return 1;', $this->code->normalize("\xEF\xBB\xBFcode->lint('return 1 + 2;'); + $this->assertTrue($ok['ok'], 'valid PHP lints clean'); + $this->assertNull($ok['error']); + + $bad = $this->code->lint('return 1 +;'); // deliberate syntax error + $this->assertFalse($bad['ok'], 'a syntax error is caught'); + $this->assertNotNull($bad['error']); + $this->assertStringNotContainsString('tigercode', $bad['error'], 'the temp path is not leaked'); + $this->assertStringNotContainsString(sys_get_temp_dir(), $bad['error']); + } + + #[Test] + public function set_active_toggles_the_flag_and_status_and_clears_last_error_on_activate(): void + { + $id = $this->code->save($this->baseRow()); + $this->code->markError($id, 'boom'); + $errored = $this->code->findById($id); + $this->assertSame(0, (int) $errored->active, 'markError deactivates'); + $this->assertSame(Tiger_Model_Code::STATUS_ERROR, $errored->status); + $this->assertSame('boom', $errored->last_error); + + $this->code->setActive($id, true); + $on = $this->code->findById($id); + $this->assertSame(1, (int) $on->active); + $this->assertSame(Tiger_Model_Code::STATUS_ACTIVE, $on->status); + $this->assertNull($on->last_error, 'activating clears the stored error'); + + $this->code->setActive($id, false); + $off = $this->code->findById($id); + $this->assertSame(0, (int) $off->active); + $this->assertSame(Tiger_Model_Code::STATUS_DRAFT, $off->status); + } + + #[Test] + public function mark_error_truncates_a_long_message(): void + { + $id = $this->code->save($this->baseRow()); + $this->code->markError($id, str_repeat('E', 5000)); + $this->assertSame(2000, strlen($this->code->findById($id)->last_error), 'the error is clamped to 2000 chars'); + } + + #[Test] + public function active_for_load_returns_only_active_rows_for_the_lang_location_and_scope_in_priority_order(): void + { + // Two active PHP/global rows (priority order) + noise that must be excluded. + $this->code->save($this->baseRow(['name' => 'B', 'priority' => 20, 'active' => 1, 'status' => Tiger_Model_Code::STATUS_ACTIVE])); + $this->code->save($this->baseRow(['name' => 'A', 'priority' => 5, 'active' => 1, 'status' => Tiger_Model_Code::STATUS_ACTIVE])); + $this->code->save($this->baseRow(['name' => 'Inactive', 'active' => 0])); + $this->code->save($this->baseRow(['name' => 'Admin', 'run_location' => Tiger_Model_Code::LOC_ADMIN, 'active' => 1, 'status' => Tiger_Model_Code::STATUS_ACTIVE])); + $this->code->save($this->baseRow(['name' => 'JS', 'language' => Tiger_Model_Code::LANG_JS, 'active' => 1, 'status' => Tiger_Model_Code::STATUS_ACTIVE])); + + $rows = $this->code->activeForLoad(Tiger_Model_Code::LANG_PHP, Tiger_Model_Code::LOC_GLOBAL, ''); + $names = []; + foreach ($rows as $r) { $names[] = $r->name; } + $this->assertSame(['A', 'B'], $names, 'only active php/global rows, in priority order'); + } + + #[Test] + public function active_client_returns_client_injected_languages_only(): void + { + $this->code->save($this->baseRow(['name' => 'CSS', 'language' => Tiger_Model_Code::LANG_CSS, 'run_location' => Tiger_Model_Code::LOC_FRONTEND, 'active' => 1, 'status' => Tiger_Model_Code::STATUS_ACTIVE])); + $this->code->save($this->baseRow(['name' => 'PHP', 'language' => Tiger_Model_Code::LANG_PHP, 'run_location' => Tiger_Model_Code::LOC_FRONTEND, 'active' => 1, 'status' => Tiger_Model_Code::STATUS_ACTIVE])); + + $rows = $this->code->activeClient(Tiger_Model_Code::LOC_FRONTEND, ''); + $names = []; + foreach ($rows as $r) { $names[] = $r->name; } + $this->assertSame(['CSS'], $names, 'server-executed php is excluded from the client manifest'); + } + + #[Test] + public function restore_version_copies_an_old_body_back_without_reactivating(): void + { + $id = $this->code->save($this->baseRow(['code' => 'return "v1";'])); + $this->code->save(['name' => 'Snippet', 'language' => Tiger_Model_Code::LANG_PHP, 'code' => 'return "v2";', + 'run_location' => Tiger_Model_Code::LOC_GLOBAL, 'priority' => 10, 'active' => 1, 'status' => Tiger_Model_Code::STATUS_ACTIVE], $id); + + $this->code->restoreVersion($id, 1); + $restored = $this->code->findById($id); + $this->assertSame('return "v1";', $restored->code, 'the version-1 body is restored'); + // restoreVersion copies the snapshotted status but does NOT re-flip active on the row. + $this->assertSame(1, (int) $restored->active, 'restore does not touch the active flag (executable-code safety)'); + } + + #[Test] + public function restore_version_throws_for_a_missing_version(): void + { + $id = $this->code->save($this->baseRow()); + $this->expectException(\RuntimeException::class); + $this->code->restoreVersion($id, 99); + } + + #[Test] + public function datatable_filters_by_language_and_search(): void + { + $this->code->save($this->baseRow(['name' => 'Alpha Helper', 'description' => 'does alpha', 'language' => Tiger_Model_Code::LANG_PHP])); + $this->code->save($this->baseRow(['name' => 'Beta Style', 'description' => 'css thing', 'language' => Tiger_Model_Code::LANG_CSS])); + + $all = $this->code->datatable(['limit' => 25]); + $this->assertGreaterThanOrEqual(2, $all['total']); + + $php = $this->code->datatable(['language' => Tiger_Model_Code::LANG_PHP, 'limit' => 25]); + foreach ($php['rows'] as $r) { $this->assertSame(Tiger_Model_Code::LANG_PHP, $r['language']); } + + $search = $this->code->datatable(['search' => 'Alpha', 'limit' => 25]); + $this->assertSame(1, $search['filtered']); + $this->assertSame('Alpha Helper', $search['rows'][0]['name']); + } +} diff --git a/tests/Integration/Model/ContactAddressTest.php b/tests/Integration/Model/ContactAddressTest.php new file mode 100644 index 0000000..3f5c118 --- /dev/null +++ b/tests/Integration/Model/ContactAddressTest.php @@ -0,0 +1,150 @@ +create(['line1' => '1 Tiger Way', 'city' => 'Reno', 'region' => 'NV', 'postal' => '89501', 'country' => 'US']); + + $this->assertNotSame('', $id, 'create() returns the minted id'); + $row = $addr->findById($id); + $this->assertSame('1 Tiger Way', $row->line1); + $this->assertSame('Reno', $row->city); + } + + #[Test] + public function an_org_address_link_carries_the_relationship_and_reads_back_primary_first(): void + { + $orgId = Tiger_Uuid::v7(); + $addr = new Tiger_Model_Address(); + $link = new Tiger_Model_OrgAddress(); + + $billing = $addr->create(['line1' => '10 Billing St', 'city' => 'Reno']); + $shipping = $addr->create(['line1' => '20 Ship Rd', 'city' => 'Sparks']); + + // Insert the non-primary first, the primary second — proving the ORDER, not insert time. + $link->insert(['org_id' => $orgId, 'address_id' => $shipping, 'label' => 'shipping', 'is_primary' => 0]); + $link->insert(['org_id' => $orgId, 'address_id' => $billing, 'label' => 'billing', 'is_primary' => 1]); + + $links = $link->findByOrg($orgId); + $this->assertCount(2, $links, 'findByOrg returns every link for the org'); + + $rows = $link->withAddress($orgId); + $this->assertCount(2, $rows); + $this->assertSame('billing', $rows[0]['label'], 'the primary link sorts first'); + $this->assertSame(1, (int) $rows[0]['is_primary']); + $this->assertSame('10 Billing St', $rows[0]['line1'], 'the join carries the underlying location'); + $this->assertArrayHasKey('link_id', $rows[0], 'the link PK is aliased to the generic link_id'); + $this->assertSame('20 Ship Rd', $rows[1]['line1']); + } + + #[Test] + public function a_soft_deleted_org_address_link_drops_out_of_the_read_shape(): void + { + $orgId = Tiger_Uuid::v7(); + $addr = new Tiger_Model_Address(); + $link = new Tiger_Model_OrgAddress(); + + $a = $addr->create(['line1' => 'Live']); + $b = $addr->create(['line1' => 'Gone']); + $link->insert(['org_id' => $orgId, 'address_id' => $a, 'label' => 'home', 'is_primary' => 1]); + $goneId = $link->insert(['org_id' => $orgId, 'address_id' => $b, 'label' => 'old', 'is_primary' => 0]); + + $link->softDelete($this->db->quoteInto('org_address_id = ?', $goneId)); + + $rows = $link->withAddress($orgId); + $this->assertCount(1, $rows, 'the soft-deleted link is filtered out'); + $this->assertSame('Live', $rows[0]['line1']); + } + + #[Test] + public function a_user_address_link_reads_back_joined_to_the_location(): void + { + $userId = Tiger_Uuid::v7(); + $addr = new Tiger_Model_Address(); + $link = new Tiger_Model_UserAddress(); + + $home = $addr->create(['line1' => '5 Home Ln', 'city' => 'Truckee']); + $link->insert(['user_id' => $userId, 'address_id' => $home, 'label' => 'home', 'is_primary' => 1]); + + $this->assertCount(1, $link->findByUser($userId)); + $rows = $link->withAddress($userId); + $this->assertCount(1, $rows); + $this->assertSame('5 Home Ln', $rows[0]['line1']); + $this->assertSame('home', $rows[0]['label']); + $this->assertArrayHasKey('link_id', $rows[0]); + } + + #[Test] + public function an_org_contact_link_reads_back_joined_to_the_channel(): void + { + $orgId = Tiger_Uuid::v7(); + $contact = new Tiger_Model_Contact(); + $link = new Tiger_Model_OrgContact(); + + $phone = $contact->insert(['kind' => 'phone', 'type' => 'work', 'value' => '+17755551234']); + $email = $contact->insert(['kind' => 'email', 'type' => 'billing', 'value' => 'ap@example.test']); + + $link->insert(['org_id' => $orgId, 'contact_id' => $phone, 'label' => 'main', 'is_primary' => 0]); + $link->insert(['org_id' => $orgId, 'contact_id' => $email, 'label' => 'ap', 'is_primary' => 1]); + + $this->assertCount(2, $link->findByOrg($orgId)); + $rows = $link->withContact($orgId); + $this->assertSame('ap', $rows[0]['label'], 'the primary contact sorts first'); + $this->assertSame('email', $rows[0]['kind']); + $this->assertSame('ap@example.test', $rows[0]['value']); + $this->assertArrayHasKey('link_id', $rows[0]); + } + + #[Test] + public function a_user_contact_link_reads_back_joined_to_the_channel(): void + { + $userId = Tiger_Uuid::v7(); + $contact = new Tiger_Model_Contact(); + $link = new Tiger_Model_UserContact(); + + $cell = $contact->insert(['kind' => 'phone', 'type' => 'cell', 'value' => '+17755559999']); + $link->insert(['user_id' => $userId, 'contact_id' => $cell, 'label' => 'cell', 'is_primary' => 1]); + + $this->assertCount(1, $link->findByUser($userId)); + $rows = $link->withContact($userId); + $this->assertCount(1, $rows); + $this->assertSame('phone', $rows[0]['kind']); + $this->assertSame('+17755559999', $rows[0]['value']); + $this->assertSame('cell', $rows[0]['label']); + } +} diff --git a/tests/Integration/Model/LoginAnalyticsTest.php b/tests/Integration/Model/LoginAnalyticsTest.php new file mode 100644 index 0000000..9adf6db --- /dev/null +++ b/tests/Integration/Model/LoginAnalyticsTest.php @@ -0,0 +1,100 @@ +login = new Tiger_Model_Login(); + } + + private function attempt(array $data, int $ageSeconds = 0): string + { + $id = $this->login->record($data); + if ($ageSeconds > 0) { + $this->db->update('login', ['created_at' => date('Y-m-d H:i:s', time() - $ageSeconds)], + $this->db->quoteInto('login_id = ?', $id)); + } + return $id; + } + + #[Test] + public function top_failures_ranks_the_most_targeted_identifiers_and_flags_real_accounts(): void + { + $realUser = Tiger_Uuid::v7(); + + // victim@ — 3 failures against a REAL account (user_id set on at least one). + $this->attempt(['identifier' => 'victim@example.test', 'result' => Tiger_Model_Login::RESULT_FAILURE, 'user_id' => $realUser]); + $this->attempt(['identifier' => 'victim@example.test', 'result' => Tiger_Model_Login::RESULT_LOCKED, 'user_id' => $realUser]); + $this->attempt(['identifier' => 'victim@example.test', 'result' => Tiger_Model_Login::RESULT_FAILURE]); + // noise@ — 1 failure, never a real account. + $this->attempt(['identifier' => 'noise@example.test', 'result' => Tiger_Model_Login::RESULT_FAILURE]); + // A SUCCESS must never count toward "being brute-forced". + $this->attempt(['identifier' => 'victim@example.test', 'result' => Tiger_Model_Login::RESULT_SUCCESS, 'user_id' => $realUser]); + + $top = $this->login->topFailures(3600, 5); + $this->assertNotEmpty($top); + $this->assertSame('victim@example.test', $top[0]['identifier'], 'the most-failed identifier ranks first'); + $this->assertSame(3, $top[0]['attempts'], 'only the 3 non-success attempts are counted'); + $this->assertTrue($top[0]['existing'], 'a spray at a real account is flagged existing=true'); + + $byId = []; + foreach ($top as $r) { $byId[$r['identifier']] = $r; } + $this->assertFalse($byId['noise@example.test']['existing'], 'a miss-only identifier is existing=false'); + } + + #[Test] + public function top_failures_excludes_attempts_outside_the_window(): void + { + $this->attempt(['identifier' => 'old@example.test', 'result' => Tiger_Model_Login::RESULT_FAILURE], 7200); + $top = $this->login->topFailures(3600, 5); + $ids = array_column($top, 'identifier'); + $this->assertNotContains('old@example.test', $ids, 'an attempt older than the window is excluded'); + } + + #[Test] + public function recent_for_user_returns_history_newest_first(): void + { + $user = Tiger_Uuid::v7(); + $this->attempt(['user_id' => $user, 'identifier' => 'u@example.test', 'result' => Tiger_Model_Login::RESULT_FAILURE], 60); + $this->attempt(['user_id' => $user, 'identifier' => 'u@example.test', 'result' => Tiger_Model_Login::RESULT_SUCCESS]); + + $rows = $this->login->recentForUser($user, 20); + $this->assertCount(2, $rows); + $this->assertSame(Tiger_Model_Login::RESULT_SUCCESS, $rows->current()->result, 'the newest attempt is first'); + } + + #[Test] + public function purge_older_than_removes_only_aged_rows(): void + { + $fresh = $this->attempt(['identifier' => 'fresh@example.test', 'result' => Tiger_Model_Login::RESULT_SUCCESS]); + $aged = $this->attempt(['identifier' => 'aged@example.test', 'result' => Tiger_Model_Login::RESULT_FAILURE], 40 * 86400); + + $removed = $this->login->purgeOlderThan(30); + $this->assertGreaterThanOrEqual(1, $removed, 'the 40-day-old row is purged at a 30-day threshold'); + + $freshLeft = (int) $this->db->fetchOne($this->db->quoteInto('SELECT COUNT(*) FROM login WHERE login_id = ?', $fresh)); + $agedLeft = (int) $this->db->fetchOne($this->db->quoteInto('SELECT COUNT(*) FROM login WHERE login_id = ?', $aged)); + $this->assertSame(1, $freshLeft, 'a recent row survives'); + $this->assertSame(0, $agedLeft, 'the aged row is gone'); + } +} diff --git a/tests/Integration/Model/MediaLibraryTest.php b/tests/Integration/Model/MediaLibraryTest.php new file mode 100644 index 0000000..087c4d3 --- /dev/null +++ b/tests/Integration/Model/MediaLibraryTest.php @@ -0,0 +1,247 @@ +media = new Tiger_Model_Media(); + + $this->priorConfig = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + Zend_Registry::set('Zend_Config', new Zend_Config([ + 'media' => [ + 'default_disk' => 'local', + 'disks' => ['local' => ['adapter' => 'filesystem', 'public_url' => '/_media', 'public_root' => 'public/_media', 'private_root' => 'storage/media']], + 'allow' => ['image' => 'jpg,png', 'document' => 'pdf', 'video' => 'mp4', 'audio' => 'mp3', 'archive' => 'zip'], + ], + ])); + Tiger_Media_Storage::reset(); + } + + protected function tearDown(): void + { + Zend_Registry::set('Zend_Config', $this->priorConfig ?? new Zend_Config([])); + Tiger_Media_Storage::reset(); + parent::tearDown(); + } + + private function insertMedia(array $overrides): string + { + return $this->media->insert(array_merge([ + 'org_id' => '', + 'disk' => 'local', + 'visibility' => Tiger_Model_Media::VISIBILITY_PUBLIC, + 'kind' => Tiger_Model_Media::KIND_IMAGE, + 'storage_key' => 'images/' . bin2hex(random_bytes(6)) . '.jpg', + 'filename' => 'file.jpg', + 'scan_status' => Tiger_Model_Media::SCAN_CLEAN, + ], $overrides)); + } + + // ---- static helpers ----------------------------------------------------- + + #[Test] + public function kind_folder_maps_each_kind_and_defaults_to_files(): void + { + $this->assertSame('images', Tiger_Model_Media::kindFolder(Tiger_Model_Media::KIND_IMAGE)); + $this->assertSame('docs', Tiger_Model_Media::kindFolder(Tiger_Model_Media::KIND_PDF)); + $this->assertSame('docs', Tiger_Model_Media::kindFolder(Tiger_Model_Media::KIND_DOCUMENT)); + $this->assertSame('files', Tiger_Model_Media::kindFolder(Tiger_Model_Media::KIND_ARCHIVE)); + $this->assertSame('files', Tiger_Model_Media::kindFolder('bogus'), 'an unknown kind falls back to files'); + } + + #[Test] + public function slugify_produces_a_safe_lowercase_token(): void + { + $this->assertSame('my-cool-file', Tiger_Model_Media::slugify('My Cool File!')); + $this->assertSame('a-b', Tiger_Model_Media::slugify(' a...b ')); + $this->assertSame('', Tiger_Model_Media::slugify('***'), 'nothing survivable → empty string'); + } + + #[Test] + public function storage_base_is_random_when_obfuscated_and_readable_otherwise(): void + { + $obf = Tiger_Model_Media::storageBase('My Photo.JPG', true); + $this->assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $obf, 'obfuscated = a bare 32-hex key'); + + $readable = Tiger_Model_Media::storageBase('My Photo.JPG', false); + $this->assertStringStartsWith('my-photo-', $readable, 'readable = the slug + a short random suffix'); + $this->assertMatchesRegularExpression('/^my-photo-[0-9a-f]{8}$/', $readable); + + // A filename with no slug-able characters still yields a random key. + $this->assertMatchesRegularExpression('/^[0-9a-f]{32}$/', Tiger_Model_Media::storageBase('***.jpg', false)); + } + + #[Test] + public function is_obfuscated_key_recognises_bare_hex_but_not_a_readable_key(): void + { + $this->assertTrue(Tiger_Model_Media::isObfuscatedKey('images/' . str_repeat('a', 32) . '.jpg')); + $this->assertFalse(Tiger_Model_Media::isObfuscatedKey('images/my-photo-1a2b3c4d.jpg'), 'a -8hex suffix is a readable key'); + $this->assertFalse(Tiger_Model_Media::isObfuscatedKey('images/logo.png')); + } + + #[Test] + public function download_name_hides_the_original_for_obfuscated_files_only(): void + { + $obf = ['storage_key' => 'docs/' . str_repeat('b', 32) . '.pdf', 'filename' => 'Secret Contract.pdf']; + $this->assertSame(str_repeat('b', 32) . '.pdf', Tiger_Model_Media::downloadName($obf), 'obfuscated downloads under the random name'); + + $readable = ['storage_key' => 'docs/report-1a2b3c4d.pdf', 'filename' => 'Q3 Report.pdf']; + $this->assertSame('Q3 Report.pdf', Tiger_Model_Media::downloadName($readable), 'readable downloads under the original name'); + } + + #[Test] + public function obfuscate_default_and_setting_scope(): void + { + $this->assertTrue(Tiger_Model_Media::obfuscateDefault(Tiger_Model_Media::VISIBILITY_PRIVATE), 'private defaults to obfuscated'); + $this->assertFalse(Tiger_Model_Media::obfuscateDefault(Tiger_Model_Media::VISIBILITY_PUBLIC), 'public defaults to readable'); + + $this->assertSame([Tiger_Model_Config::SCOPE_GLOBAL, ''], Tiger_Model_Media::settingScope(''), 'no org → global scope'); + $org = Tiger_Uuid::v7(); + $this->assertSame([Tiger_Model_Config::SCOPE_ORG, $org], Tiger_Model_Media::settingScope($org), 'an org → org scope'); + } + + #[Test] + public function obfuscate_enabled_falls_back_default_then_reads_config(): void + { + // Nothing stored → the built-in default per visibility. + $this->assertTrue($this->media->obfuscateEnabled(Tiger_Model_Media::VISIBILITY_PRIVATE, '')); + $this->assertFalse($this->media->obfuscateEnabled(Tiger_Model_Media::VISIBILITY_PUBLIC, '')); + + // A stored global override flips the public default on. + (new Tiger_Model_Config())->set(Tiger_Model_Config::SCOPE_GLOBAL, '', Tiger_Model_Media::CFG_OBFUSCATE . 'public', '1'); + $this->assertTrue($this->media->obfuscateEnabled(Tiger_Model_Media::VISIBILITY_PUBLIC, ''), 'a stored "1" enables obfuscation'); + } + + // ---- variants + url variant path --------------------------------------- + + #[Test] + public function variants_decodes_json_and_thumb_url_prefers_the_thumbnail(): void + { + $variants = ['thumbnail' => ['key' => 'images/thumb_logo.png']]; + $id = $this->insertMedia(['visibility' => Tiger_Model_Media::VISIBILITY_PUBLIC, 'storage_key' => 'images/logo.png', 'variants' => json_encode($variants)]); + $row = $this->media->findById($id)->toArray(); + + $this->assertSame($variants, $this->media->variants($row), 'the variants JSON decodes to an array'); + $this->assertSame('/_media/images/thumb_logo.png', $this->media->thumbUrl($row), 'thumbUrl serves the thumbnail variant'); + $this->assertSame('/_media/images/thumb_logo.png', $this->media->url($row, 'thumbnail'), 'url() resolves a named variant key'); + + // An unknown variant falls back to the original. + $this->assertSame('/_media/images/logo.png', $this->media->url($row, 'nope')); + } + + #[Test] + public function thumb_url_falls_back_to_the_original_when_there_is_no_thumbnail(): void + { + $id = $this->insertMedia(['storage_key' => 'images/plain.jpg', 'variants' => null]); + $row = $this->media->findById($id)->toArray(); + $this->assertSame('/_media/images/plain.jpg', $this->media->thumbUrl($row)); + } + + #[Test] + public function url_of_a_row_with_no_storage_key_is_empty(): void + { + $this->assertSame('', $this->media->url(['visibility' => 'public', 'storage_key' => ''])); + } + + #[Test] + public function a_private_variant_streams_through_the_acl_route_with_the_variant_segment(): void + { + $id = $this->insertMedia([ + 'visibility' => Tiger_Model_Media::VISIBILITY_PRIVATE, + 'kind' => Tiger_Model_Media::KIND_PDF, + 'storage_key' => 'docs/secret.pdf', + 'variants' => json_encode(['preview' => ['key' => 'docs/secret_preview.png']]), + ]); + $row = $this->media->findById($id)->toArray(); + + $url = $this->media->url($row, 'preview'); + $this->assertStringStartsWith('/media/file/serve/id/' . rawurlencode($id), $url, 'private variant goes through the gate'); + $this->assertStringEndsWith('/v/preview', $url, 'the variant segment rides on the route'); + $this->assertStringNotContainsString('secret', $url, 'no private key leaks in the URL'); + } + + // ---- search + datatable ------------------------------------------------- + + #[Test] + public function search_returns_nothing_for_a_blank_term(): void + { + $this->assertSame([], $this->media->search(' ', ['role' => 'user', 'orgId' => 'x'])); + } + + #[Test] + public function search_surfaces_public_clean_media_to_a_guest_but_hides_private_and_infected(): void + { + $org = Tiger_Uuid::v7(); + $this->insertMedia(['visibility' => 'public', 'title' => 'Vacation Sunset', 'filename' => 'vacation-sunset.jpg', 'scan_status' => Tiger_Model_Media::SCAN_CLEAN]); + $this->insertMedia(['visibility' => 'private', 'org_id' => $org, 'title' => 'Vacation Private', 'filename' => 'vacation-priv.jpg', 'scan_status' => Tiger_Model_Media::SCAN_CLEAN]); + $this->insertMedia(['visibility' => 'public', 'title' => 'Vacation Infected', 'filename' => 'vacation-bad.jpg', 'scan_status' => Tiger_Model_Media::SCAN_INFECTED]); + + $guest = $this->media->search('vacation', ['role' => 'guest', 'orgId' => '']); + $titles = array_column($guest, 'title'); + $this->assertContains('Vacation Sunset', $titles, 'public+clean is visible to a guest'); + $this->assertNotContains('Vacation Private', $titles, 'private is hidden from a guest'); + $this->assertNotContains('Vacation Infected', $titles, 'infected media is never surfaced'); + } + + #[Test] + public function search_shows_a_signed_in_owner_their_own_private_media(): void + { + $org = Tiger_Uuid::v7(); + $this->insertMedia(['visibility' => 'private', 'org_id' => $org, 'title' => 'Manuscript Draft', 'filename' => 'manuscript.pdf', 'kind' => 'pdf', 'scan_status' => Tiger_Model_Media::SCAN_CLEAN]); + $this->insertMedia(['visibility' => 'private', 'org_id' => Tiger_Uuid::v7(), 'title' => 'Manuscript Other', 'filename' => 'other.pdf', 'kind' => 'pdf', 'scan_status' => Tiger_Model_Media::SCAN_CLEAN]); + + $mine = $this->media->search('manuscript', ['role' => 'user', 'orgId' => $org]); + $titles = array_column($mine, 'title'); + $this->assertContains('Manuscript Draft', $titles, 'the owning org sees its private media'); + $this->assertNotContains('Manuscript Other', $titles, 'another org\'s private media stays hidden'); + } + + #[Test] + public function datatable_counts_filters_by_kind_and_searches(): void + { + $this->insertMedia(['kind' => 'image', 'title' => 'Logo Alpha', 'filename' => 'logo-alpha.png']); + $this->insertMedia(['kind' => 'image', 'title' => 'Logo Beta', 'filename' => 'logo-beta.png']); + $this->insertMedia(['kind' => 'pdf', 'title' => 'A Document', 'filename' => 'doc.pdf']); + + $all = $this->media->datatable(['limit' => 24]); + $this->assertSame(3, $all['total']); + + $images = $this->media->datatable(['kind' => 'image', 'limit' => 24]); + $this->assertSame(2, $images['filtered']); + foreach ($images['rows'] as $r) { $this->assertSame('image', $r['kind']); } + + $search = $this->media->datatable(['search' => 'Alpha', 'limit' => 24]); + $this->assertSame(1, $search['filtered']); + $this->assertSame('Logo Alpha', $search['rows'][0]['title']); + } +} diff --git a/tests/Integration/Model/MenuAdminTest.php b/tests/Integration/Model/MenuAdminTest.php new file mode 100644 index 0000000..97b2ae2 --- /dev/null +++ b/tests/Integration/Model/MenuAdminTest.php @@ -0,0 +1,130 @@ +menu = new Tiger_Model_Menu(); + } + + private function insertItem(array $overrides): string + { + return $this->menu->insert(array_merge([ + 'org_id' => '', + 'menu_key' => 'primary', + 'parent_id' => null, + 'sort_order' => 0, + 'label' => 'Item', + 'status' => Tiger_Model_Menu::STATUS_PUBLISHED, + ], $overrides)); + } + + #[Test] + public function items_for_editor_includes_drafts_but_tree_front_end_excludes_them(): void + { + $org = Tiger_Uuid::v7(); + $this->insertItem(['org_id' => $org, 'label' => 'Live', 'status' => Tiger_Model_Menu::STATUS_PUBLISHED, 'sort_order' => 0]); + $this->insertItem(['org_id' => $org, 'label' => 'Draft', 'status' => Tiger_Model_Menu::STATUS_DRAFT, 'sort_order' => 1]); + + $editor = $this->menu->itemsForEditor('primary', $org); + $this->assertCount(2, $editor, 'the editor working set includes drafts'); + + $publishedTree = $this->menu->tree('primary', $org, true); + $this->assertCount(1, $publishedTree, 'the front-end tree excludes drafts'); + $this->assertSame('Live', $publishedTree[0]['label']); + + $draftTree = $this->menu->tree('primary', $org, false); + $this->assertCount(2, $draftTree, 'the admin tree ($onlyPublished=false) shows drafts'); + } + + #[Test] + public function next_sort_appends_at_the_end_of_a_level(): void + { + $org = Tiger_Uuid::v7(); + $this->assertSame(0, $this->menu->nextSort('primary', $org, null), 'an empty top level starts at 0'); + + $this->insertItem(['org_id' => $org, 'label' => 'A', 'sort_order' => 0]); + $this->insertItem(['org_id' => $org, 'label' => 'B', 'sort_order' => 4]); + $this->assertSame(5, $this->menu->nextSort('primary', $org, null), 'next top-level sort is max+1'); + + $parent = $this->insertItem(['org_id' => $org, 'label' => 'P', 'sort_order' => 1]); + $this->insertItem(['org_id' => $org, 'label' => 'child', 'parent_id' => $parent, 'sort_order' => 2]); + $this->assertSame(3, $this->menu->nextSort('primary', $org, $parent), 'next child sort is scoped to the parent'); + } + + #[Test] + public function keys_lists_distinct_menu_keys_optionally_scoped_by_org(): void + { + $org = Tiger_Uuid::v7(); + $this->insertItem(['org_id' => '', 'menu_key' => 'primary']); + $this->insertItem(['org_id' => '', 'menu_key' => 'footer']); + $this->insertItem(['org_id' => $org, 'menu_key' => 'sidebar']); + + $all = $this->menu->keys(); + $this->assertContains('primary', $all); + $this->assertContains('footer', $all); + $this->assertContains('sidebar', $all); + + $scoped = $this->menu->keys($org); + $this->assertSame(['sidebar'], $scoped, 'scoping by org lists only that org\'s keys'); + } + + #[Test] + public function delete_item_soft_deletes_the_whole_subtree(): void + { + $org = Tiger_Uuid::v7(); + $parent = $this->insertItem(['org_id' => $org, 'label' => 'Parent', 'sort_order' => 0]); + $child = $this->insertItem(['org_id' => $org, 'label' => 'Child', 'parent_id' => $parent, 'sort_order' => 0]); + $grand = $this->insertItem(['org_id' => $org, 'label' => 'Grandchild', 'parent_id' => $child, 'sort_order' => 0]); + $sibling = $this->insertItem(['org_id' => $org, 'label' => 'Sibling', 'sort_order' => 1]); + + $n = $this->menu->deleteItem($parent); + $this->assertSame(3, $n, 'the parent plus its two descendants were soft-deleted'); + + // The subtree is gone from the editor set; the sibling survives. + $remaining = array_column($this->menu->itemsForEditor('primary', $org), 'label'); + $this->assertSame(['Sibling'], $remaining, 'only the untouched sibling remains'); + // findById excludes deleted rows, so read the flag directly to prove the grandchild was soft-deleted. + $grandDeleted = (int) $this->db->fetchOne($this->db->quoteInto('SELECT deleted FROM menu WHERE menu_id = ?', $grand)); + $this->assertSame(1, $grandDeleted, 'the grandchild is soft-deleted, not orphaned'); + } + + #[Test] + public function datatable_lists_one_row_per_menu_with_an_item_count_and_search(): void + { + $this->insertItem(['menu_key' => 'primary', 'label' => 'Home']); + $this->insertItem(['menu_key' => 'primary', 'label' => 'About']); + $this->insertItem(['menu_key' => 'footer', 'label' => 'Legal']); + + $all = $this->menu->datatable(['limit' => 25]); + $this->assertSame(2, $all['total'], 'two distinct menus (primary, footer)'); + $byKey = []; + foreach ($all['rows'] as $r) { $byKey[$r['menu_key']] = (int) $r['items']; } + $this->assertSame(2, $byKey['primary'], 'the primary menu counts its two items'); + $this->assertSame(1, $byKey['footer']); + + $search = $this->menu->datatable(['search' => 'foot', 'limit' => 25]); + $this->assertSame(1, $search['filtered']); + $this->assertSame('footer', $search['rows'][0]['menu_key']); + } +} diff --git a/tests/Integration/Model/OpsLogModelsTest.php b/tests/Integration/Model/OpsLogModelsTest.php new file mode 100644 index 0000000..e985fd9 --- /dev/null +++ b/tests/Integration/Model/OpsLogModelsTest.php @@ -0,0 +1,220 @@ +begin('manual.zip', 'local', ['db', 'media'], 'manual'); + $sched = $b->begin('nightly.zip', 'local', ['db'], 'scheduled'); + + $mRow = $b->findById($manual); + $this->assertSame('running', $mRow->outcome, 'a backup opens running'); + $this->assertSame(1, (int) $mRow->pinned, 'manual backups are pinned (manual-remove-only)'); + $this->assertSame('manual', $mRow->source); + $this->assertSame('db,media', $mRow->components, 'components are stored comma-joined'); + + $sRow = $b->findById($sched); + $this->assertSame(0, (int) $sRow->pinned, 'scheduled backups are prunable (not pinned)'); + $this->assertSame('scheduled', $sRow->source); + } + + #[Test] + public function finish_records_the_outcome_and_only_the_supplied_fields(): void + { + $b = new Tiger_Model_Backup(); + $id = $b->begin('x.zip', 'local', ['db'], 'manual'); + + $b->finish($id, 'ok', ['storage_key' => 'backups/x.zip', 'size_bytes' => 4096, 'checksum' => str_repeat('a', 64)]); + $row = $b->findById($id); + $this->assertSame('ok', $row->outcome); + $this->assertSame('backups/x.zip', $row->storage_key); + $this->assertSame(4096, (int) $row->size_bytes); + + // An unknown outcome string is coerced to 'error'. + $id2 = $b->begin('y.zip', 'local', ['db'], 'manual'); + $b->finish($id2, 'nonsense', ['error' => 'boom']); + $this->assertSame('error', $b->findById($id2)->outcome, 'anything but ok becomes error'); + $this->assertSame('boom', $b->findById($id2)->error); + } + + #[Test] + public function recent_is_newest_first_and_prunable_excludes_pinned_and_failed(): void + { + $b = new Tiger_Model_Backup(); + + // Distinct created_at so ordering is deterministic (not tie-broken at sub-second inserts). + $stamp = function (string $id, int $agoSec): void { + $this->db->update('backup', ['created_at' => date('Y-m-d H:i:s', time() - $agoSec)], $this->db->quoteInto('backup_id = ?', $id)); + }; + + $old = $b->begin('old.zip', 'local', ['db'], 'scheduled'); + $b->finish($old, 'ok'); + $stamp($old, 3600); + $new = $b->begin('new.zip', 'local', ['db'], 'scheduled'); + $b->finish($new, 'ok'); + $stamp($new, 10); + $manual = $b->begin('keep.zip', 'local', ['db'], 'manual'); + $b->finish($manual, 'ok'); + $stamp($manual, 30); + $failed = $b->begin('bad.zip', 'local', ['db'], 'scheduled'); + $b->finish($failed, 'error'); + $stamp($failed, 20); + + $recent = $b->recent(50); + $this->assertSame('new.zip', $recent[0]['filename'], 'recent() is newest first'); + $this->assertSame('old.zip', $recent[count($recent) - 1]['filename'], 'oldest is last'); + + $prunable = array_column($b->prunable(), 'filename'); + $this->assertSame(['old.zip', 'new.zip'], $prunable, 'oldest-first, only scheduled+ok+unpinned'); + $this->assertNotContains('keep.zip', $prunable, 'a pinned/manual backup is never prunable'); + $this->assertNotContains('bad.zip', $prunable, 'a failed backup is never prunable'); + } + + // ---- ScheduleRun -------------------------------------------------------- + + #[Test] + public function schedule_begin_finish_and_last_run_ts(): void + { + $s = new Tiger_Model_ScheduleRun(); + $slot = time(); + + $this->assertNull($s->lastRunTs('backup.nightly'), 'a job that never ran has no last-run'); + + $id = $s->begin('backup.nightly', $slot, 'cron'); + $this->assertSame('running', $s->findById($id)->outcome); + + $ts = $s->lastRunTs('backup.nightly'); + $this->assertNotNull($ts); + $this->assertEqualsWithDelta(time(), $ts, 5, 'last-run is the start time'); + + $s->finish($id, 'ok', null, 1234); + $done = $s->findById($id); + $this->assertSame('ok', $done->outcome); + $this->assertSame(1234, (int) $done->duration_ms); + $this->assertNotNull($done->finished_at); + } + + #[Test] + public function is_running_flags_a_fresh_running_row_but_not_a_stale_one(): void + { + $s = new Tiger_Model_ScheduleRun(); + $id = $s->begin('report.hourly', time(), 'pseudo'); + + $this->assertTrue($s->isRunning('report.hourly'), 'a fresh running row is the overlap lock'); + $this->assertFalse($s->isRunning('some.other.job'), 'a different job is not running'); + + // Backdate the running row past the stale threshold — it should no longer count as running. + $this->db->update('schedule_run', ['started_at' => date('Y-m-d H:i:s', time() - 3600)], $this->db->quoteInto('schedule_run_id = ?', $id)); + $this->assertFalse($s->isRunning('report.hourly', 1800), 'a running row older than staleSeconds is treated as dead'); + } + + #[Test] + public function latest_per_job_and_history(): void + { + $s = new Tiger_Model_ScheduleRun(); + + $first = $s->begin('sync.job', time() - 200, 'cron'); + $s->finish($first, 'error', 'failed once', 10); + $this->db->update('schedule_run', ['started_at' => date('Y-m-d H:i:s', time() - 200)], $this->db->quoteInto('schedule_run_id = ?', $first)); + $second = $s->begin('sync.job', time(), 'cron'); + $s->finish($second, 'ok', null, 20); + $s->begin('other.job', time(), 'cron'); + + $latest = $s->latestPerJob(); + $this->assertArrayHasKey('sync.job', $latest); + $this->assertArrayHasKey('other.job', $latest); + $this->assertSame('ok', $latest['sync.job']['outcome'], 'latestPerJob keeps the newest row per key'); + + $history = $s->history('sync.job', 20); + $this->assertCount(2, $history, 'history returns every run for the job'); + $this->assertSame('ok', $history[0]['outcome'], 'history is newest first'); + } + + #[Test] + public function finish_truncates_a_long_error(): void + { + $s = new Tiger_Model_ScheduleRun(); + $id = $s->begin('noisy.job', time(), 'manual'); + $s->finish($id, 'error', str_repeat('E', 2000), 5); + $this->assertSame(1000, mb_strlen($s->findById($id)->error), 'the error is clamped to 1000 chars'); + } + + // ---- UpdateHistory ------------------------------------------------------ + + #[Test] + public function update_history_records_an_item_and_reads_back_with_a_decoded_log(): void + { + $u = new Tiger_Model_UpdateHistory(); + + $u->record([ + 'item_type' => 'module', + 'item_slug' => 'blog', + 'item_name' => 'Blog', + 'from_version' => '0.1.0', + 'to_version' => '0.2.0', + 'outcome' => Tiger_Model_UpdateHistory::OUTCOME_SUCCESS, + 'log' => ['downloaded', 'migrated', 'activated'], + ]); + + $recent = $u->recent(20); + $this->assertCount(1, $recent); + $this->assertSame('blog', $recent[0]['item_slug']); + $this->assertSame('success', $recent[0]['outcome']); + $this->assertSame(['downloaded', 'migrated', 'activated'], $recent[0]['log'], 'the JSON step log decodes to an array'); + } + + #[Test] + public function update_history_defaults_a_missing_outcome_to_failed_and_a_missing_log_to_empty(): void + { + $u = new Tiger_Model_UpdateHistory(); + $u->record(['item_slug' => 'core']); // minimal — defaults kick in + + $row = $u->recent(1)[0]; + $this->assertSame('module', $row['item_type'], 'item_type defaults to module'); + $this->assertSame('failed', $row['outcome'], 'a missing outcome defaults to failed'); + $this->assertSame([], $row['log'], 'a null log decodes to an empty array'); + } + + #[Test] + public function recent_is_newest_first_and_honors_the_limit(): void + { + $u = new Tiger_Model_UpdateHistory(); + // Distinct created_at so "newest first" is deterministic (sub-second inserts would tie). + foreach (['a' => 30, 'b' => 20, 'c' => 5] as $slug => $ago) { + $id = $u->record(['item_slug' => $slug, 'outcome' => 'success']); + $this->db->update('update_history', ['created_at' => date('Y-m-d H:i:s', time() - $ago)], $this->db->quoteInto('update_id = ?', $id)); + } + + $one = $u->recent(1); + $this->assertCount(1, $one, 'the limit is respected'); + $this->assertSame('c', $one[0]['item_slug'], 'newest first'); + } +} diff --git a/tests/Integration/Model/PageStoreTest.php b/tests/Integration/Model/PageStoreTest.php new file mode 100644 index 0000000..65b8dba --- /dev/null +++ b/tests/Integration/Model/PageStoreTest.php @@ -0,0 +1,175 @@ +page = new Tiger_Model_Page(); + } + + private function baseRow(array $overrides = []): array + { + return array_merge([ + 'org_id' => '', + 'type' => Tiger_Model_Page::TYPE_PAGE, + 'locale' => 'en', + 'format' => Tiger_Model_Page::FORMAT_HTML, + 'status' => Tiger_Model_Page::STATUS_PUBLISHED, + ], $overrides); + } + + #[Test] + public function save_inserts_and_snapshots_version_one(): void + { + $id = $this->page->save($this->baseRow(['slug' => 'welcome', 'title' => 'Welcome', 'body' => 'hi'])); + $this->assertNotSame('', $id); + + $row = $this->page->findById($id); + $this->assertSame('Welcome', $row->title); + + $versions = (new Tiger_Model_PageVersion())->recentForPage($id); + $this->assertCount(1, $versions, 'the insert snapshots version 1'); + } + + #[Test] + public function save_update_snapshots_a_new_version(): void + { + $id = $this->page->save($this->baseRow(['slug' => 'about', 'title' => 'About', 'body' => 'v1'])); + $this->page->save(['title' => 'About Us', 'body' => 'v2', 'status' => Tiger_Model_Page::STATUS_PUBLISHED, 'format' => Tiger_Model_Page::FORMAT_HTML], $id); + + $this->assertSame('About Us', $this->page->findById($id)->title); + $this->assertCount(2, (new Tiger_Model_PageVersion())->recentForPage($id), 'each save adds a version'); + } + + #[Test] + public function changing_a_pages_slug_leaves_a_301_behind(): void + { + $org = Tiger_Uuid::v7(); + $id = $this->page->save($this->baseRow(['org_id' => $org, 'slug' => 'old-url', 'title' => 'T', 'body' => 'b'])); + + $this->page->save(['slug' => 'new-url'], $id); + $this->assertSame('new-url', $this->page->findById($id)->slug, 'the slug changed'); + + $redirect = (new Tiger_Model_PageRedirect())->findFrom('old-url', 'en', $org); + $this->assertNotNull($redirect, 'a redirect from the retired slug was recorded'); + $this->assertSame('new-url', $redirect->to_slug); + $this->assertSame(301, (int) $redirect->code); + } + + #[Test] + public function reclaiming_a_slug_clears_a_stale_redirect_from_it(): void + { + $org = Tiger_Uuid::v7(); + $redirects = new Tiger_Model_PageRedirect(); + // A pre-existing redirect FROM 'fresh' (would otherwise loop when a page reclaims that slug). + $redirects->add('fresh', 'somewhere', 'en', $org); + + $id = $this->page->save($this->baseRow(['org_id' => $org, 'slug' => 'temp', 'title' => 'T', 'body' => 'b'])); + $this->page->save(['slug' => 'fresh'], $id); // rename onto the slug that had a redirect + + $this->assertNull($redirects->findFrom('fresh', 'en', $org), 'the stale redirect FROM the reclaimed slug is cleared'); + } + + #[Test] + public function restore_version_copies_an_old_body_back_and_resnapshots(): void + { + $id = $this->page->save($this->baseRow(['slug' => 'r', 'title' => 'V1', 'body' => 'body one'])); + $this->page->save(['title' => 'V2', 'body' => 'body two', 'status' => Tiger_Model_Page::STATUS_PUBLISHED, 'format' => Tiger_Model_Page::FORMAT_HTML], $id); + + $this->page->restoreVersion($id, 1); + $this->assertSame('body one', $this->page->findById($id)->body, 'the version-1 body is restored'); + $this->assertCount(3, (new Tiger_Model_PageVersion())->recentForPage($id), 'the restore itself snapshots a new version'); + } + + #[Test] + public function restore_version_throws_for_a_missing_version(): void + { + $id = $this->page->save($this->baseRow(['slug' => 'x', 'title' => 'X', 'body' => 'b'])); + $this->expectException(\RuntimeException::class); + $this->page->restoreVersion($id, 99); + } + + #[Test] + public function children_returns_published_pages_under_a_parent_in_order(): void + { + $org = Tiger_Uuid::v7(); + $parent = $this->page->insert($this->baseRow(['org_id' => $org, 'slug' => 'parent', 'title' => 'Parent'])); + + $this->page->insert($this->baseRow(['org_id' => $org, 'slug' => 'c2', 'title' => 'Zeta', 'parent_id' => $parent, 'sort_order' => 2])); + $this->page->insert($this->baseRow(['org_id' => $org, 'slug' => 'c1', 'title' => 'Alpha', 'parent_id' => $parent, 'sort_order' => 1])); + $this->page->insert($this->baseRow(['org_id' => $org, 'slug' => 'd', 'title' => 'Draft', 'parent_id' => $parent, 'sort_order' => 0, 'status' => Tiger_Model_Page::STATUS_DRAFT])); + + $kids = $this->page->children($parent, 'en', $org); + $titles = []; + foreach ($kids as $k) { $titles[] = $k->title; } + $this->assertSame(['Alpha', 'Zeta'], $titles, 'published children in sort order; the draft is excluded'); + } + + #[Test] + public function search_honors_the_publish_and_org_gate(): void + { + $org = Tiger_Uuid::v7(); + $this->page->insert($this->baseRow(['org_id' => $org, 'slug' => 'p1', 'title' => 'Photography Basics', 'body' => 'about photography'])); + $this->page->insert($this->baseRow(['org_id' => $org, 'slug' => 'p2', 'title' => 'Photography Draft', 'body' => 'photography secrets', 'status' => Tiger_Model_Page::STATUS_DRAFT])); + $this->page->insert($this->baseRow(['org_id' => Tiger_Uuid::v7(), 'slug' => 'p3', 'title' => 'Photography Elsewhere', 'body' => 'photography elsewhere'])); + + $hits = $this->page->search('photography', 'en', $org, 20); + $titles = array_column($hits, 'title'); + $this->assertContains('Photography Basics', $titles, 'a published in-scope page is found'); + $this->assertNotContains('Photography Draft', $titles, 'a draft is never surfaced'); + $this->assertNotContains('Photography Elsewhere', $titles, 'another tenant\'s page is not surfaced'); + } + + #[Test] + public function search_returns_empty_for_a_blank_term(): void + { + $this->assertSame([], $this->page->search(' ', 'en', '')); + } + + #[Test] + public function datatable_counts_filters_and_searches(): void + { + $org = Tiger_Uuid::v7(); + $this->page->insert($this->baseRow(['org_id' => $org, 'slug' => 'a', 'title' => 'Alpha Page', 'status' => Tiger_Model_Page::STATUS_PUBLISHED])); + $this->page->insert($this->baseRow(['org_id' => $org, 'slug' => 'b', 'title' => 'Beta Page', 'status' => Tiger_Model_Page::STATUS_DRAFT])); + $this->page->insert($this->baseRow(['org_id' => $org, 'page_key' => 'lay', 'slug' => null, 'title' => 'Layout', 'type' => Tiger_Model_Page::TYPE_LAYOUT])); + + $all = $this->page->datatable(['limit' => 25]); + $this->assertGreaterThanOrEqual(3, $all['total']); + + $published = $this->page->datatable(['status' => Tiger_Model_Page::STATUS_PUBLISHED, 'limit' => 25]); + foreach ($published['rows'] as $r) { $this->assertSame(Tiger_Model_Page::STATUS_PUBLISHED, $r['status']); } + + $layouts = $this->page->datatable(['type' => Tiger_Model_Page::TYPE_LAYOUT, 'limit' => 25]); + foreach ($layouts['rows'] as $r) { $this->assertSame(Tiger_Model_Page::TYPE_LAYOUT, $r['type']); } + + $search = $this->page->datatable(['search' => 'Alpha', 'limit' => 25]); + $this->assertSame(1, $search['filtered']); + $this->assertSame('Alpha Page', $search['rows'][0]['title']); + } +} diff --git a/tests/Integration/Model/ResponseEnvelopeTest.php b/tests/Integration/Model/ResponseEnvelopeTest.php new file mode 100644 index 0000000..9ff0f1e --- /dev/null +++ b/tests/Integration/Model/ResponseEnvelopeTest.php @@ -0,0 +1,125 @@ +priorTranslate = Zend_Registry::isRegistered('Zend_Translate') ? Zend_Registry::get('Zend_Translate') : null; + } + + protected function tearDown(): void + { + // Restore the registry EXACTLY: if there was no translator before, UNSET it — setting it to + // null would leave it registered-as-null, and code guarded by isRegistered() would then call + // a method on null (this leaked into SchemaServiceTest's menu→nav resolution). + $reg = Zend_Registry::getInstance(); + if ($this->priorTranslate !== null) { + Zend_Registry::set('Zend_Translate', $this->priorTranslate); + } elseif ($reg->offsetExists('Zend_Translate')) { + $reg->offsetUnset('Zend_Translate'); + } + parent::tearDown(); + } + + #[Test] + public function response_object_has_the_default_envelope_shape(): void + { + $r = new Tiger_Model_ResponseObject(); + $this->assertSame(0, $r->result, 'result defaults to failure (0)'); + $this->assertNull($r->data); + $this->assertNull($r->redirect); + $this->assertNull($r->form); + $this->assertSame([], $r->messages, 'messages defaults to an empty list'); + } + + #[Test] + public function message_object_falls_back_to_the_literal_text_when_no_translator_is_wired(): void + { + $reg = Zend_Registry::getInstance(); + if ($reg->offsetExists('Zend_Translate')) { $reg->offsetUnset('Zend_Translate'); } + $m = new Tiger_Model_MessageObject('Saved successfully.', 'success', 'email'); + $this->assertSame('Saved successfully.', $m->message, 'plain text passes through untranslated'); + $this->assertSame('success', $m->class); + $this->assertSame('email', $m->field); + } + + #[Test] + public function message_object_translates_a_known_key_in_the_current_locale(): void + { + $translate = new Zend_Translate([ + 'adapter' => 'array', + 'content' => ['msg.saved' => 'Your changes were saved'], + 'locale' => 'en', + ]); + Zend_Registry::set('Zend_Translate', $translate); + + $m = new Tiger_Model_MessageObject('msg.saved', 'info'); + $this->assertSame('Your changes were saved', $m->message, 'a translated key is resolved'); + $this->assertSame('info', $m->class); + $this->assertNull($m->field, 'field defaults to null'); + } + + #[Test] + public function page_redirect_add_find_and_clear_with_the_tenant_cascade(): void + { + $redirect = new Tiger_Model_PageRedirect(); + $orgA = Tiger_Uuid::v7(); + + // A global redirect and a tenant override for the same retired slug + locale. + $redirect->add('old-home', 'home', 'en', ''); + $redirect->add('old-home', 'welcome', 'en', $orgA); + + // The tenant row wins over global for the owning org… + $seenByA = $redirect->findFrom('old-home', 'en', $orgA); + $this->assertNotNull($seenByA); + $this->assertSame('welcome', $seenByA->to_slug, 'a tenant redirect overrides the global one'); + $this->assertSame(301, (int) $seenByA->code, 'the default code is a 301'); + + // …and a foreign/blank org falls back to the global redirect. + $seenGlobal = $redirect->findFrom('old-home', 'en', ''); + $this->assertSame('home', $seenGlobal->to_slug); + + // A different locale doesn't match. + $this->assertNull($redirect->findFrom('old-home', 'fr', ''), 'the locale must match'); + + // clearFrom removes exactly the (slug, locale, org) redirect. + $n = $redirect->clearFrom('old-home', 'en', $orgA); + $this->assertSame(1, $n, 'one tenant redirect was cleared'); + // With the tenant row gone, org A now cascades to the surviving global redirect. + $afterClear = $redirect->findFrom('old-home', 'en', $orgA); + $this->assertNotNull($afterClear, 'clearing the tenant redirect leaves global intact'); + $this->assertSame('home', $afterClear->to_slug, 'org A falls back to the global redirect'); + $this->assertSame('', $afterClear->org_id); + } +} diff --git a/tests/Integration/Model/UserCredentialFactorsTest.php b/tests/Integration/Model/UserCredentialFactorsTest.php new file mode 100644 index 0000000..28a5ecb --- /dev/null +++ b/tests/Integration/Model/UserCredentialFactorsTest.php @@ -0,0 +1,176 @@ + ['crypto' => ['key' => self::KEY], 'security' => ['pepper' => self::PEP]], + ], true)); + $this->cred = new Tiger_Model_UserCredential(); + } + + private function makeUser(): string + { + return (new Tiger_Model_User())->insert(['email' => 'fac-' . bin2hex(random_bytes(8)) . '@example.test']); + } + + #[Test] + public function an_sms_factor_is_unverified_until_confirmed_and_only_then_resolves(): void + { + $user = $this->makeUser(); + $id = $this->cred->addSms($user, '+15551230000'); + + $row = $this->cred->factor($user, Tiger_Model_UserCredential::TYPE_SMS, '+15551230000'); + $this->assertNotNull($row, 'the factor is stored, keyed by its identifier'); + $this->assertNull($row->verified_at, 'a new SMS factor starts UNVERIFIED'); + + // An unverified phone must not resolve for "log in by phone". + $this->assertNull( + $this->cred->findVerifiedByIdentifier(Tiger_Model_UserCredential::TYPE_SMS, '+15551230000'), + 'an unconfirmed factor cannot authenticate' + ); + + $this->cred->markVerified($id); + $resolved = $this->cred->findVerifiedByIdentifier(Tiger_Model_UserCredential::TYPE_SMS, '+15551230000'); + $this->assertNotNull($resolved, 'once confirmed it resolves by identifier'); + $this->assertSame($user, $resolved->user_id); + } + + #[Test] + public function factors_for_lists_all_of_a_users_factors(): void + { + $user = $this->makeUser(); + $this->cred->setPassword($user, 'a strong secret here'); + $this->cred->addSms($user, '+15551239999'); + + $types = []; + foreach ($this->cred->factorsFor($user) as $f) { $types[] = $f->type; } + sort($types); + $this->assertSame([Tiger_Model_UserCredential::TYPE_PASSWORD, Tiger_Model_UserCredential::TYPE_SMS], $types); + } + + #[Test] + public function password_credential_returns_the_row_and_touch_stamps_last_used(): void + { + $user = $this->makeUser(); + $id = $this->cred->setPassword($user, 'initial secret value'); + + $cred = $this->cred->passwordCredential($user); + $this->assertNotNull($cred); + $this->assertSame($id, $cred->credential_id); + $this->assertSame(Tiger_Model_UserCredential::TYPE_PASSWORD, $cred->type); + + $this->assertNull($cred->last_used_at, 'not used yet'); + $this->cred->touch($id); + $this->assertNotNull($this->cred->findById($id)->last_used_at, 'touch stamps last_used_at'); + } + + #[Test] + public function replacing_a_password_archives_the_outgoing_hash_into_history(): void + { + $user = $this->makeUser(); + $this->cred->setPassword($user, 'first password one'); + // Replacing the password should push the OLD hash into password_history (reuse-prevention). + $this->cred->setPassword($user, 'second password two'); + + $history = (new Tiger_Model_PasswordHistory())->recentForUser($user, 10); + $this->assertGreaterThanOrEqual(1, count($history), 'the outgoing hash is archived on replace'); + // Still exactly one live password credential (idempotent replace, not a second row). + $passwords = 0; + foreach ($this->cred->factorsFor($user) as $f) { + if ($f->type === Tiger_Model_UserCredential::TYPE_PASSWORD) { $passwords++; } + } + $this->assertSame(1, $passwords, 'replace keeps one password row, not two'); + $this->assertTrue($this->cred->verifyPassword($user, 'second password two'), 'the new password verifies'); + } + + #[Test] + public function is_locked_out_reflects_the_lockout_window(): void + { + $user = $this->makeUser(); + $id = $this->cred->setPassword($user, 'lockme downplease'); + + $this->assertFalse($this->cred->isLockedOut($this->cred->passwordCredential($user)), 'a fresh credential is not locked'); + $this->assertFalse($this->cred->isLockedOut(null), 'a null credential is not locked'); + + // Walk failures to the cap; the credential should then read as locked. + for ($i = 0; $i < Tiger_Model_UserCredential::MAX_FAILURES; $i++) { + $this->cred->recordFailure($id); + } + $this->assertTrue($this->cred->isLockedOut($this->cred->passwordCredential($user)), 'the cap trips the lockout'); + + // A future locked_until reads locked; a past one does not. + $this->cred->update(['locked_until' => date('Y-m-d H:i:s', time() - 60)], + $this->db->quoteInto('credential_id = ?', $id)); + $this->assertFalse($this->cred->isLockedOut($this->cred->passwordCredential($user)), 'an expired lockout is not locked'); + } + + #[Test] + public function totp_admin_reads_and_removal(): void + { + $user = $this->makeUser(); + $this->assertFalse($this->cred->hasActiveTotp($user), 'no TOTP to start'); + $this->assertSame(0, $this->cred->recoveryCount($user)); + + $this->cred->replaceTotp($user, Tiger_Crypto::encrypt('JBSWY3DPEHPK3PXP'), ['h1', 'h2', 'h3']); + $this->assertTrue($this->cred->hasActiveTotp($user), 'TOTP is now active'); + $this->assertSame(3, $this->cred->recoveryCount($user), 'three recovery codes were stored'); + + $this->cred->removeTotp($user); + $this->assertFalse($this->cred->hasActiveTotp($user), 'removeTotp drops the secret'); + $this->assertSame(0, $this->cred->recoveryCount($user), 'and every recovery code'); + } + + #[Test] + public function rekey_totp_secrets_reencrypts_each_secret_losslessly(): void + { + $userA = $this->makeUser(); + $userB = $this->makeUser(); + $this->cred->replaceTotp($userA, Tiger_Crypto::encrypt('JBSWY3DPEHPK3PXP'), []); + $this->cred->replaceTotp($userB, Tiger_Crypto::encrypt('KRSXG5CTMVRXEZLU'), []); + + $before = (string) $this->cred->activeTotp($userA)->secret; + + $result = $this->cred->rekeyTotpSecrets(); + $this->assertSame(2, $result['rekeyed'], 'both TOTP secrets were re-encrypted'); + $this->assertSame(0, $result['failed']); + + // Re-encryption changes the ciphertext (fresh nonce) but not the plaintext. + $after = (string) $this->cred->activeTotp($userA)->secret; + $this->assertSame('JBSWY3DPEHPK3PXP', Tiger_Crypto::decrypt($after), 'the secret still decrypts to the original'); + $this->assertNotSame($before, $after, 'the stored ciphertext was refreshed under the current key'); + } +} diff --git a/tests/Integration/Policy/PasswordExpiryTest.php b/tests/Integration/Policy/PasswordExpiryTest.php new file mode 100644 index 0000000..d68526d --- /dev/null +++ b/tests/Integration/Policy/PasswordExpiryTest.php @@ -0,0 +1,91 @@ +policy = new Tiger_Policy_Password(); + $this->cred = new Tiger_Model_UserCredential(); + } + + private function configure(array $password): void + { + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['password' => $password]], true)); + } + + private function makeUserWithPassword(): string + { + $userId = (new Tiger_Model_User())->insert(['email' => 'exp-' . bin2hex(random_bytes(8)) . '@example.test']); + $this->cred->setPassword($userId, 'initialpass1'); + return $userId; + } + + /** Move the user's password credential verified_at back by $days days. */ + private function ageCredential(string $userId, int $days): void + { + $row = $this->cred->passwordCredential($userId); + $this->cred->update( + ['verified_at' => date('Y-m-d H:i:s', time() - ($days * 86400))], + $this->db->quoteInto('credential_id = ?', $row->credential_id) + ); + } + + #[Test] + public function expiry_is_off_by_default(): void + { + $this->configure([]); // no max_age_days + $user = $this->makeUserWithPassword(); + $this->ageCredential($user, 3650); // 10 years old — but expiry is off + $this->assertFalse($this->policy->isExpired($user), 'with rotation off, a password never expires'); + } + + #[Test] + public function a_fresh_password_is_not_expired_when_a_max_age_is_set(): void + { + $this->configure(['max_age_days' => 90]); + $user = $this->makeUserWithPassword(); // verified just now + $this->assertFalse($this->policy->isExpired($user), 'a just-set password is inside the window'); + } + + #[Test] + public function a_password_older_than_the_window_is_expired(): void + { + $this->configure(['max_age_days' => 90]); + $user = $this->makeUserWithPassword(); + $this->ageCredential($user, 120); // older than 90 days + $this->assertTrue($this->policy->isExpired($user)); + } + + #[Test] + public function a_user_without_a_password_credential_is_never_expired(): void + { + $this->configure(['max_age_days' => 90]); + $userId = (new Tiger_Model_User())->insert(['email' => 'nopw-' . bin2hex(random_bytes(8)) . '@example.test']); + $this->assertFalse($this->policy->isExpired($userId), 'no credential → no expiry'); + } +} diff --git a/tests/Integration/Service/AuthenticationEnrollmentTest.php b/tests/Integration/Service/AuthenticationEnrollmentTest.php new file mode 100644 index 0000000..8cc7c80 --- /dev/null +++ b/tests/Integration/Service/AuthenticationEnrollmentTest.php @@ -0,0 +1,334 @@ +priorUnitTestMode = Zend_Session::$_unitTestEnabled; + Zend_Session::$_unitTestEnabled = true; + $_SESSION = []; + Zend_Auth::getInstance()->setStorage(new Zend_Auth_Storage_Session()); + Zend_Auth::getInstance()->clearIdentity(); + + $this->useCrypto(); + $this->auth = new Tiger_Service_Authentication(); + } + + protected function tearDown(): void + { + Zend_Auth::getInstance()->clearIdentity(); + $_SESSION = []; + Zend_Session::$_unitTestEnabled = $this->priorUnitTestMode; + parent::tearDown(); + } + + /** Seed the crypto key + pepper (and optionally extra tiger.* config) into the registry. */ + private function useCrypto(array $extraTiger = []): void + { + $tiger = array_replace_recursive([ + 'crypto' => ['key' => self::KEY], + 'security' => ['pepper' => self::PEPPER], + ], $extraTiger); + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => $tiger], true)); + } + + private function email(): string + { + return 'enroll-' . bin2hex(random_bytes(8)) . '@example.test'; + } + + /** Create an ACTIVE user with a password credential; returns [user_id, email]. */ + private function makeUserWithPassword(string $password): array + { + $email = $this->email(); + $uid = (new Tiger_Model_User())->insert(['email' => $email, 'status' => 'active']); + (new Tiger_Model_UserCredential())->setPassword($uid, $password); + return [$uid, $email]; + } + + // ----- TOTP enrollment wizard -------------------------------------------- + + #[Test] + public function begin_totp_enrollment_returns_a_secret_and_otpauth_uri_and_stashes_the_pending_factor(): void + { + [, $email] = $this->makeUserWithPassword('enroll me pw 123'); + $this->auth->login($email, 'enroll me pw 123'); + + $enroll = $this->auth->beginTotpEnrollment(); + + $this->assertIsArray($enroll); + $this->assertNotSame('', (string) $enroll['secret'], 'a fresh TOTP secret is minted'); + $this->assertStringStartsWith('otpauth://totp/', $enroll['otpauth'], 'the enrollment carries a scannable otpauth URI'); + $this->assertCount(Tiger_Service_Authentication::RECOVERY_COUNT, $enroll['recovery'], 'a full set of recovery codes is offered'); + // The factor is NOT live yet — nothing persisted until a code confirms it. + $this->assertFalse($this->auth->getTwoFactorStatus()['enabled'], 'begin does not enable 2FA on its own'); + } + + #[Test] + public function begin_totp_enrollment_is_null_for_a_guest(): void + { + $this->assertNull($this->auth->beginTotpEnrollment(), 'no identity => nothing to enroll'); + } + + #[Test] + public function activate_totp_confirms_the_pending_secret_with_a_live_code_and_goes_live(): void + { + [, $email] = $this->makeUserWithPassword('activate pw 1234'); + $this->auth->login($email, 'activate pw 1234'); + $enroll = $this->auth->beginTotpEnrollment(); + + $liveCode = Tiger_Auth_Totp::codeAt($enroll['secret'], intdiv(time(), 30)); + $this->assertTrue($this->auth->activateTotp($liveCode), 'a live code confirms the enrollment'); + $this->assertTrue($this->auth->getTwoFactorStatus()['enabled'], 'the factor is now live'); + } + + #[Test] + public function activate_totp_rejects_a_wrong_code_and_stays_disabled(): void + { + [, $email] = $this->makeUserWithPassword('activate bad 123'); + $this->auth->login($email, 'activate bad 123'); + $this->auth->beginTotpEnrollment(); + + $this->assertFalse($this->auth->activateTotp('000000'), 'a wrong code does not activate'); + $this->assertFalse($this->auth->getTwoFactorStatus()['enabled'], '2FA stays off'); + } + + #[Test] + public function activate_totp_without_a_pending_enrollment_is_false(): void + { + [, $email] = $this->makeUserWithPassword('no pending pw 1'); + $this->auth->login($email, 'no pending pw 1'); + // No beginTotpEnrollment() → no pending secret in the session. + $this->assertFalse($this->auth->activateTotp('123456'), 'nothing pending => false'); + } + + #[Test] + public function disable_totp_requires_a_live_code_and_then_removes_the_factor(): void + { + [, $email] = $this->makeUserWithPassword('disable pw 1234'); + $this->auth->login($email, 'disable pw 1234'); + $enroll = $this->auth->beginTotpEnrollment(); + $this->auth->activateTotp(Tiger_Auth_Totp::codeAt($enroll['secret'], intdiv(time(), 30))); + $this->assertTrue($this->auth->getTwoFactorStatus()['enabled']); + + $this->assertFalse($this->auth->disableTotp('000000'), 'a wrong code cannot strip the second factor'); + $this->assertTrue($this->auth->getTwoFactorStatus()['enabled'], 'still enabled after a bad attempt'); + + $live = Tiger_Auth_Totp::codeAt($enroll['secret'], intdiv(time(), 30)); + $this->assertTrue($this->auth->disableTotp($live), 'a live code disables it'); + $this->assertFalse($this->auth->getTwoFactorStatus()['enabled'], '2FA is off again'); + } + + #[Test] + public function disable_totp_also_accepts_a_recovery_code(): void + { + [, $email] = $this->makeUserWithPassword('recovery off pw1'); + $this->auth->login($email, 'recovery off pw1'); + $enroll = $this->auth->beginTotpEnrollment(); + $this->auth->activateTotp(Tiger_Auth_Totp::codeAt($enroll['secret'], intdiv(time(), 30))); + + // A recovery code (as the UI shows it, with a separator) disables the factor. + $this->assertTrue($this->auth->disableTotp($enroll['recovery'][0]), 'a recovery code disables 2FA'); + $this->assertFalse($this->auth->getTwoFactorStatus()['enabled']); + } + + #[Test] + public function disable_totp_is_false_for_a_guest(): void + { + $this->assertFalse($this->auth->disableTotp('123456')); + } + + // ----- stateless token identity ------------------------------------------ + + #[Test] + public function identity_from_token_resolves_the_owning_user_in_their_primary_org(): void + { + $email = $this->email(); + $uid = (new Tiger_Model_User())->insert(['email' => $email, 'status' => 'active']); + $orgId = (new Tiger_Model_Org())->insert(['name' => 'Tok Org', 'slug' => 'org-' . bin2hex(random_bytes(6))]); + (new Tiger_Model_OrgUser())->insert(['org_id' => $orgId, 'user_id' => $uid, 'role' => 'admin', 'status' => 'active']); + $token = (new Tiger_Model_UserCredential())->createToken($uid)['token']; + + $identity = $this->auth->identityFromToken($token); + + $this->assertIsObject($identity, 'a valid token resolves an identity'); + $this->assertSame($uid, $identity->user_id); + $this->assertSame($orgId, $identity->org_id, 'the primary active org is resolved onto the token identity'); + $this->assertSame('admin', $identity->role); + // Stateless: nothing was written to the session. + $this->assertFalse($this->auth->isAuthenticated(), 'token resolution never establishes a session'); + } + + #[Test] + public function identity_from_token_is_null_for_a_bogus_token(): void + { + $this->assertNull($this->auth->identityFromToken('tgr_deadbeefdead_' . str_repeat('a', 48)), 'unknown token => null'); + $this->assertNull($this->auth->identityFromToken('not-even-a-token'), 'malformed token => null'); + } + + // ----- live-role refresh ------------------------------------------------- + + #[Test] + public function refresh_role_re_resolves_the_live_membership_role(): void + { + $email = $this->email(); + $uid = (new Tiger_Model_User())->insert(['email' => $email, 'status' => 'active']); + (new Tiger_Model_UserCredential())->setPassword($uid, 'refresh role pw1'); + $orgId = (new Tiger_Model_Org())->insert(['name' => 'RR Org', 'slug' => 'org-' . bin2hex(random_bytes(6))]); + (new Tiger_Model_OrgUser())->insert(['org_id' => $orgId, 'user_id' => $uid, 'role' => 'manager', 'status' => 'active']); + + $this->auth->login($email, 'refresh role pw1'); + // Simulate the while-locked guest downgrade the authorization plugin applies. + $this->auth->getIdentity()->role = 'guest'; + + $this->auth->refreshRole(); + $this->assertSame('manager', $this->auth->getIdentity()->role, 'the live org role is restored'); + } + + #[Test] + public function refresh_role_is_a_noop_for_a_guest(): void + { + $this->auth->refreshRole(); // no identity — must not throw + $this->assertFalse($this->auth->isAuthenticated()); + } + + // ----- emailed screen-unlock code ---------------------------------------- + + #[Test] + public function request_unlock_code_issues_an_email_login_challenge_for_the_locked_identity(): void + { + [$uid, $email] = $this->makeUserWithPassword('unlock code pw12'); + $this->auth->login($email, 'unlock code pw12'); + $this->auth->lock(); + + $this->auth->requestUnlockCode(); + + $this->assertGreaterThanOrEqual( + 1, + (new Tiger_Model_AuthChallenge())->countRecent($uid, 'email_login', 3600), + 'a fresh unlock code is issued to the locked user' + ); + } + + #[Test] + public function unlock_with_code_clears_the_lock_on_a_valid_code_and_rejects_a_wrong_one(): void + { + [$uid, $email] = $this->makeUserWithPassword('unlock with code1'); + $this->auth->login($email, 'unlock with code1'); + $this->auth->lock(); + $this->assertTrue($this->auth->isLocked()); + + // Seed a known email_login challenge (requestLoginCode's code is random + hashed). + (new Tiger_Model_AuthChallenge())->issue($uid, 'email_login', '778899', 600); + + $this->assertFalse($this->auth->unlockWithCode('000000'), 'a wrong code does not unlock'); + $this->assertTrue($this->auth->isLocked(), 'still locked after a bad code'); + + $this->assertTrue($this->auth->unlockWithCode('778899'), 'the right code unlocks'); + $this->assertFalse($this->auth->isLocked(), 'the screen is unlocked without a fresh login'); + $this->assertTrue($this->auth->isAuthenticated(), 'identity was never dropped — a lock is not a logout'); + } + + #[Test] + public function unlock_with_code_is_false_for_a_guest(): void + { + $this->assertFalse($this->auth->unlockWithCode('123456')); + } + + // ----- auto-logout config + session poller ------------------------------- + + #[Test] + public function autologout_config_defaults_when_nothing_is_configured(): void + { + $cfg = $this->auth->autologoutConfig(); + $this->assertFalse($cfg['enabled'], 'off by default'); + $this->assertSame(900, $cfg['seconds']); + $this->assertSame('logout', $cfg['action']); + $this->assertSame(60, $cfg['warn']); + } + + #[Test] + public function autologout_config_reads_the_live_config_cascade(): void + { + $this->useCrypto(['session' => ['autologout' => [ + 'enabled' => 1, 'seconds' => 45, 'action' => 'lock', 'warn' => 15, + ]]]); + + $cfg = $this->auth->autologoutConfig(); + $this->assertTrue($cfg['enabled']); + $this->assertSame(45, $cfg['seconds']); + $this->assertSame('lock', $cfg['action']); + $this->assertSame(15, $cfg['warn']); + } + + #[Test] + public function autologout_config_floors_seconds_at_thirty(): void + { + $this->useCrypto(['session' => ['autologout' => ['enabled' => 1, 'seconds' => 5]]]); + $this->assertSame(30, $this->auth->autologoutConfig()['seconds'], 'seconds is floored to a sane minimum'); + } + + #[Test] + public function session_status_reports_unauthenticated_for_a_guest(): void + { + $s = $this->auth->sessionStatus(); + $this->assertFalse($s['authenticated']); + $this->assertSame(0, $s['remaining']); + } + + #[Test] + public function session_status_reports_time_left_and_only_resets_the_clock_on_real_activity(): void + { + $this->useCrypto(['session' => ['autologout' => ['enabled' => 1, 'seconds' => 600]]]); + [, $email] = $this->makeUserWithPassword('session poll pw12'); + $this->auth->login($email, 'session poll pw12'); + + $first = $this->auth->sessionStatus(false); // first sighting starts the clock + $this->assertTrue($first['authenticated']); + $this->assertTrue($first['enabled']); + $this->assertLessThanOrEqual(600, $first['remaining']); + $this->assertGreaterThan(0, $first['remaining']); + + // An active poll resets the inactivity clock to full. + $active = $this->auth->sessionStatus(true); + $this->assertSame(600, $active['remaining'], 'genuine interaction resets the clock to the full window'); + } +} diff --git a/tests/Integration/Service/TokenServiceTest.php b/tests/Integration/Service/TokenServiceTest.php new file mode 100644 index 0000000..7955554 --- /dev/null +++ b/tests/Integration/Service/TokenServiceTest.php @@ -0,0 +1,170 @@ +insert([ + 'email' => 'tok-' . bin2hex(random_bytes(8)) . '@example.test', + 'status' => 'active', + ]); + $this->login($uid); // writes a non-persistent Zend_Auth identity {user_id: $uid} + return $uid; + } + + private function response(array $message): object + { + return (new Tiger_Service_Token($message))->getResponse(); + } + + // ----- create ------------------------------------------------------------ + + #[Test] + public function create_mints_a_token_and_returns_the_plaintext_once(): void + { + $this->signedInUser(); + $res = $this->response(['action' => 'create']); + + $this->assertSame(1, $res->result); + $this->assertNotEmpty($res->data['token'], 'the plaintext token is returned exactly once'); + $this->assertStringStartsWith('tgr_', $res->data['token']); + $this->assertNotEmpty($res->data['prefix']); + $this->assertSame($res->data['prefix'] . '', substr($res->data['token'], 4, 12), 'the returned prefix matches the token'); + } + + // ----- all --------------------------------------------------------------- + + #[Test] + public function all_lists_the_users_tokens_by_prefix_never_the_secret(): void + { + $this->signedInUser(); + $minted = $this->response(['action' => 'create'])->data; + + $res = $this->response(['action' => 'all']); + $this->assertSame(1, $res->result); + $this->assertNotEmpty($res->data['tokens']); + $row = $res->data['tokens'][0]; + $this->assertSame($minted['prefix'], $row['prefix']); + $this->assertArrayHasKey('credential_id', $row); + $this->assertArrayNotHasKey('secret', $row, 'the secret is never listed'); + } + + // ----- revoke ------------------------------------------------------------ + + #[Test] + public function revoke_soft_deletes_a_token_the_caller_owns(): void + { + $uid = $this->signedInUser(); + $this->response(['action' => 'create']); + $credId = (new Tiger_Model_UserCredential())->tokensFor($uid)[0]['credential_id']; + + $res = $this->response(['action' => 'revoke', 'credential_id' => $credId]); + $this->assertSame(1, $res->result); + $this->assertSame([], (new Tiger_Model_UserCredential())->tokensFor($uid), 'the revoked token no longer lists'); + } + + #[Test] + public function revoke_without_a_credential_id_is_an_error(): void + { + $this->signedInUser(); + $res = $this->response(['action' => 'revoke']); + $this->assertSame(0, $res->result); + $this->assertSame('core.api.error.general', $res->messages[0]->message); + } + + #[Test] + public function revoke_only_touches_the_callers_own_token(): void + { + // Owner A mints a token. + $ownerA = (new Tiger_Model_User())->insert(['email' => 'a-' . bin2hex(random_bytes(6)) . '@ex.test', 'status' => 'active']); + $aTokenId = (new Tiger_Model_UserCredential())->createToken($ownerA)['credential_id']; + + // Attacker B tries to revoke A's token id. + $this->signedInUser(); + $res = $this->response(['action' => 'revoke', 'credential_id' => $aTokenId]); + + $this->assertSame(1, $res->result, 'the call succeeds shape-wise (no enumeration)'); + $this->assertNotEmpty((new Tiger_Model_UserCredential())->tokensFor($ownerA), "A's token is untouched — ownership guards the WHERE"); + } + + // ----- login-required guard ---------------------------------------------- + + #[Test] + public function every_action_requires_a_signed_in_user(): void + { + Zend_Auth::getInstance()->clearIdentity(); // guest + foreach (['create', 'all', 'revoke'] as $action) { + $res = $this->response(['action' => $action]); + $this->assertSame(0, $res->result, "$action is refused for a guest"); + $this->assertSame('core.api.error.login_required', $res->messages[0]->message); + } + } + + // ----- stateless Bearer-token dispatch through the /api gateway ----------- + + #[Test] + public function a_bearer_token_authenticates_an_api_call_statelessly_through_the_gateway(): void + { + // Covers Tiger_Ajax_ServiceFactory's Authorization: Bearer path — the token resolves the request + // identity WITHOUT a session, then the same call the browser makes (module=tiger,service=token) + // dispatches as that user. Kernel Tiger_Service_* is reachable over /api gated purely by ACL. + $uid = (new Tiger_Model_User())->insert(['email' => 'bearer-' . bin2hex(random_bytes(6)) . '@ex.test', 'status' => 'active']); + $token = (new Tiger_Model_UserCredential())->createToken($uid)['token']; + + // Allow the base authenticated role to reach the token service (a fresh user resolves to 'user'). + $acl = new Zend_Acl(); + $acl->addRole(new Zend_Acl_Role('guest')); + $acl->addRole(new Zend_Acl_Role('user'), 'guest'); + $acl->addResource(new Zend_Acl_Resource('Tiger_Service_Token')); + $acl->allow('user', 'Tiger_Service_Token'); + Zend_Registry::set('Zend_Acl', $acl); + + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $token; + try { + $req = new Zend_Controller_Request_Http(); + $req->setParam('svc_module', 'tiger'); + $req->setParam('svc_service', 'token'); + $req->setParam('svc_action', 'all'); + + $res = (new Tiger_Ajax_ServiceFactory($req))->getResponse(); + + $this->assertSame(1, $res->result, 'the token authenticates the call end-to-end'); + $this->assertNotEmpty($res->data['tokens'], "the caller's own token lists"); + $this->assertTrue( + (bool) Zend_Registry::get('tiger.auth.stateless'), + 'the request is flagged stateless (CSRF-exempt) for a token client' + ); + } finally { + unset($_SERVER['HTTP_AUTHORIZATION']); + $reg = Zend_Registry::getInstance(); + if ($reg->offsetExists('Zend_Acl')) { $reg->offsetUnset('Zend_Acl'); } + if ($reg->offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); } + } + } +} diff --git a/tests/Integration/Session/DbTableSaveHandlerTest.php b/tests/Integration/Session/DbTableSaveHandlerTest.php new file mode 100644 index 0000000..d31e97a --- /dev/null +++ b/tests/Integration/Session/DbTableSaveHandlerTest.php @@ -0,0 +1,176 @@ + 'session', + 'primary' => 'session_id', + 'modifiedColumn' => 'modified', + 'dataColumn' => 'data', + 'lifetimeColumn' => 'lifetime', + ]); + } + + private function rowCount(string $id): int + { + return (int) $this->db->fetchOne('SELECT COUNT(*) FROM session WHERE session_id = ?', [$id]); + } + + #[Test] + public function open_and_close_return_true(): void + { + $h = $this->handler(); + $this->assertTrue($h->open('/tmp', 'PHPSESSID')); + $this->assertTrue($h->close()); + } + + #[Test] + public function write_rejects_a_malformed_session_id(): void + { + $this->assertFalse($this->handler()->write('bad id with spaces!', 'x')); + $this->assertFalse($this->handler()->write('', 'x')); + } + + #[Test] + public function an_empty_anonymous_session_is_not_persisted(): void + { + $id = 'guestempty0000000000000000000001'; + $this->assertTrue($this->handler()->write($id, ''), 'a skipped empty guest write still reports success'); + $this->assertSame(0, $this->rowCount($id), 'no row per anonymous hit'); + } + + #[Test] + public function write_persists_a_session_and_stamps_the_identity_context(): void + { + $this->login('user-writer', 'org-writer', 'manager'); + $id = 'sess00000000000000000000000000writer'; + $this->assertTrue($this->handler()->write($id, 'the-payload')); + + $row = $this->db->fetchRow('SELECT * FROM session WHERE session_id = ?', [$id]); + $this->assertSame('the-payload', $row['data']); + $this->assertSame('user-writer', $row['user_id']); + $this->assertSame('manager', $row['role']); + $this->assertSame('org-writer', $row['org_id']); + $this->assertSame(604800, (int) $row['lifetime'], 'an authenticated non-privileged role gets the long TTL'); + } + + #[Test] + public function a_privileged_role_gets_the_short_sensitive_ttl(): void + { + $this->login('user-admin', 'org-a', 'admin'); + $id = 'sess0000000000000000000000000adminx'; + $this->handler()->write($id, 'x'); + + $row = $this->db->fetchRow('SELECT lifetime FROM session WHERE session_id = ?', [$id]); + $this->assertSame(28800, (int) $row['lifetime'], 'admin/superadmin/developer get the 8h tier'); + } + + #[Test] + public function a_guest_session_with_data_persists_at_the_guest_ttl(): void + { + // A guest (no identity) with non-empty data IS persisted — only empty guest sessions skip. + $this->logout(); + $id = 'sess0000000000000000000000000guestd'; + $this->handler()->write($id, 'cart=1'); + + $row = $this->db->fetchRow('SELECT role, lifetime, user_id FROM session WHERE session_id = ?', [$id]); + $this->assertSame('guest', $row['role']); + $this->assertNull($row['user_id']); + $this->assertSame(86400, (int) $row['lifetime'], 'the 1d guest tier'); + } + + #[Test] + public function the_ttl_is_config_overridable_live(): void + { + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['session' => ['ttl' => ['privileged' => '3600']]]])); + $this->login('user-admin2', 'org-a', 'superadmin'); + $id = 'sess000000000000000000000000cfgttl'; + $this->handler()->write($id, 'x'); + + $row = $this->db->fetchRow('SELECT lifetime FROM session WHERE session_id = ?', [$id]); + $this->assertSame(3600, (int) $row['lifetime'], 'the config tier overrides the default privileged TTL'); + } + + #[Test] + public function read_returns_the_written_payload(): void + { + $this->login('user-reader', 'org-r', 'user'); + $id = 'sess0000000000000000000000000reader'; + $this->handler()->write($id, 'session-body'); + + $this->assertSame('session-body', $this->handler()->read($id)); + } + + #[Test] + public function read_of_an_unknown_id_is_empty(): void + { + $this->assertSame('', $this->handler()->read('sess000000000000000000000000unknown')); + } + + #[Test] + public function read_of_an_expired_row_returns_empty_and_destroys_it(): void + { + $this->login('user-exp', 'org-e', 'user'); + $id = 'sess00000000000000000000000expired0'; + $this->handler()->write($id, 'stale'); + + // Age the row so (modified + lifetime) < now — the read must treat it as expired. + $this->db->update('session', ['modified' => time() - 999999999], ['session_id = ?' => $id]); + + $this->assertSame('', $this->handler()->read($id), 'an expired session reads empty'); + $this->assertSame(0, $this->rowCount($id), 'and it is destroyed on read'); + } + + #[Test] + public function destroy_removes_the_row(): void + { + $this->login('user-del', 'org-d', 'user'); + $id = 'sess0000000000000000000000000delete'; + $this->handler()->write($id, 'x'); + $this->assertSame(1, $this->rowCount($id)); + + $this->assertTrue($this->handler()->destroy($id)); + $this->assertSame(0, $this->rowCount($id)); + } + + #[Test] + public function gc_reaps_expired_rows_and_never_throws(): void + { + $this->login('user-gc', 'org-g', 'user'); + $live = 'sess000000000000000000000000gclive0'; + $dead = 'sess000000000000000000000000gcdead0'; + $this->handler()->write($live, 'fresh'); + $this->handler()->write($dead, 'old'); + $this->db->update('session', ['modified' => time() - 999999999], ['session_id = ?' => $dead]); + + $this->assertTrue($this->handler()->gc(0)); + $this->assertSame(1, $this->rowCount($live), 'a live session survives GC'); + $this->assertSame(0, $this->rowCount($dead), 'an expired session is reaped'); + } +} diff --git a/tests/Integration/View/ViewHelperTest.php b/tests/Integration/View/ViewHelperTest.php new file mode 100644 index 0000000..2f473d9 --- /dev/null +++ b/tests/Integration/View/ViewHelperTest.php @@ -0,0 +1,126 @@ +menu('primary') ?>`). + * `menu()` with no key returns the helper (fluent); a key with no menu rows renders ''. + * - Tiger_View_Helper_MediaField — a media-picker form field (hidden input + live preview + Choose/ + * Clear buttons). An empty value renders the bare control; an existing image value server-renders an + * `` thumbnail (resolved from the `media` row), a non-image renders a file-icon tile. + * + * Both reach the DB (menu tree / media row), hence integration. Neither uses the view object, so the + * helpers are called directly. + */ +#[CoversClass(Tiger_View_Helper_Menu::class)] +#[CoversClass(Tiger_View_Helper_MediaField::class)] +final class ViewHelperTest extends IntegrationTestCase +{ + // ----- Menu ------------------------------------------------------------------------------- + + #[Test] + public function menu_with_no_key_returns_the_helper_for_fluent_access(): void + { + $h = new Tiger_View_Helper_Menu(); + $this->assertSame($h, $h->menu()); + } + + #[Test] + public function menu_with_an_unknown_key_renders_nothing(): void + { + $h = new Tiger_View_Helper_Menu(); + $this->assertSame('', $h->menu('no-such-menu'), 'an empty tree yields no markup'); + } + + // ----- MediaField ------------------------------------------------------------------------- + + #[Test] + public function media_field_with_no_value_renders_the_bare_control(): void + { + $h = new Tiger_View_Helper_MediaField(); + $html = $h->mediaField('hero_image', '', ['kind' => 'image', 'label' => 'Hero image']); + + $this->assertStringContainsString('data-media-field', $html); + $this->assertStringContainsString('name="hero_image"', $html); + $this->assertStringContainsString('id="hero_image"', $html); + $this->assertStringContainsString('data-kind="image"', $html); + $this->assertStringContainsString('Hero image', $html); + // No value => the Clear button is hidden and there is no preview . + $this->assertStringContainsString('data-media-clear hidden', $html); + $this->assertStringNotContainsString('mediaField('gallery', '', ['id' => 'gallery-picker', 'multiple' => true]); + + $this->assertStringContainsString('id="gallery-picker"', $html); + $this->assertStringContainsString('data-multiple="1"', $html); + } + + #[Test] + public function media_field_server_renders_an_image_thumbnail_for_an_existing_value(): void + { + $mediaId = (new Tiger_Model_Media())->insert([ + 'org_id' => '', + 'disk' => 'local', + 'storage_key' => 'uploads/hero.jpg', + 'visibility' => 'public', + 'kind' => 'image', + 'mime_type' => 'image/jpeg', + 'filename' => 'hero.jpg', + ]); + + $html = (new Tiger_View_Helper_MediaField())->mediaField('hero_image', $mediaId); + + $this->assertStringContainsString('assertStringContainsString('value="' . $mediaId . '"', $html); + // A value is present => the Clear button is NOT hidden. + $this->assertStringNotContainsString('data-media-clear hidden', $html); + } + + #[Test] + public function media_field_renders_a_file_tile_for_a_non_image_value(): void + { + $mediaId = (new Tiger_Model_Media())->insert([ + 'org_id' => '', + 'disk' => 'local', + 'storage_key' => 'uploads/manual.pdf', + 'visibility' => 'public', + 'kind' => 'document', + 'mime_type' => 'application/pdf', + 'filename' => 'manual.pdf', + ]); + + $html = (new Tiger_View_Helper_MediaField())->mediaField('attachment', $mediaId); + + $this->assertStringContainsString('fa-file', $html, 'a non-image value shows a file icon'); + $this->assertStringNotContainsString('insert([ + 'org_id' => '', 'disk' => 'local', 'storage_key' => 'x.jpg', + 'visibility' => 'public', 'kind' => 'image', 'filename' => 'x.jpg', + ]); + + $html = (new Tiger_View_Helper_MediaField())->mediaField('imgs', $mediaId, ['multiple' => true]); + $this->assertStringNotContainsString(' */ + public static $caps = ['suggest', 'geocode', 'reverse', 'ip']; + /** @var bool when true every operation throws (to exercise the facade's graceful-empty catch) */ + public static $throw = false; + /** @var Tiger_Location_Place[] */ + public static $suggestReturn = []; + /** @var Tiger_Location_Place[] */ + public static $geocodeReturn = []; + public static $reverseReturn; // ?Tiger_Location_Place + public static $ipReturn; // ?Tiger_Location_Place + /** @var array the config the facade passed the last instance */ + public static $seenConfig = []; + + public static function reset(): void + { + self::$caps = ['suggest', 'geocode', 'reverse', 'ip']; + self::$throw = false; + self::$suggestReturn = []; + self::$geocodeReturn = []; + self::$reverseReturn = null; + self::$ipReturn = null; + self::$seenConfig = []; + } + + public function __construct(array $config = []) + { + parent::__construct($config); + self::$seenConfig = $config; + } + + public function capabilities(): array + { + return self::$caps; + } + + public function suggest(string $query, array $opts = []): array + { + if (self::$throw) { throw new Tiger_Location_Exception('boom'); } + return self::$suggestReturn; + } + + public function geocode(string $query, array $opts = []): array + { + if (self::$throw) { throw new Tiger_Location_Exception('boom'); } + return self::$geocodeReturn; + } + + public function reverse(float $lat, float $lng, array $opts = []): ?Tiger_Location_Place + { + if (self::$throw) { throw new Tiger_Location_Exception('boom'); } + return self::$reverseReturn; + } + + public function ip(string $ip, array $opts = []): ?Tiger_Location_Place + { + if (self::$throw) { throw new Tiger_Location_Exception('boom'); } + return self::$ipReturn; + } +} + +/** + * A do-nothing concrete adapter: overrides nothing, so it exercises Tiger_Location_Adapter_Abstract's + * defaults (empty capabilities, the class-suffix label, and every operation throwing "unsupported"). + */ +class Tiger_Test_BareAdapter extends Tiger_Location_Adapter_Abstract +{ +} + +/** + * A class that is NOT a Tiger_Location_Adapter_Interface but constructs cleanly with the adapter + * signature — so the facade's "resolved instance isn't an adapter → null" branch can be exercised + * without a constructor error escaping. + */ +class Tiger_Test_NotAnAdapter +{ + public function __construct(array $config = []) {} +} + +/** + * A Nominatim subclass whose network hop (_getJson) is replaced by a canned payload, so the real + * parse path (_search / _place / reverse mapping) runs deterministically. It also records every URL + * the adapter built, so a test can assert URL construction. + */ +class Tiger_Test_NominatimAdapter extends Tiger_Location_Adapter_Nominatim +{ + /** @var mixed the value the next _getJson returns */ + public $canned; + /** @var string[] URLs the adapter asked for */ + public $urls = []; + + protected function _getJson(string $url, array $headers = [], int $timeout = 8): ?array + { + $this->urls[] = $url; + return $this->canned; + } +} + +/** + * An ip-api subclass with the network hop stubbed. Records the URL so the fields/key construction can + * be asserted, and returns a canned decoded body. + */ +class Tiger_Test_IpApiAdapter extends Tiger_Location_Adapter_IpApi +{ + /** @var mixed the value the next _getJson returns */ + public $canned; + /** @var string[] URLs the adapter asked for */ + public $urls = []; + + protected function _getJson(string $url, array $headers = [], int $timeout = 8): ?array + { + $this->urls[] = $url; + return $this->canned; + } +} + +/** + * An AWS-Location subclass with the signed-POST hop (_call) stubbed to a canned decoded body, so the + * request-shape (_textBody) and response-parse (_mapPlace) paths run without AWS. Records the op + body. + */ +class Tiger_Test_AwsAdapter extends Tiger_Location_Adapter_Aws +{ + /** @var mixed the value the next _call returns */ + public $canned; + /** @var array */ + public $calls = []; + + protected function _call(string $op, array $body): ?array + { + $this->calls[] = ['op' => $op, 'body' => $body]; + return $this->canned; + } +} diff --git a/tests/Unit/Acl/AclInternalsTest.php b/tests/Unit/Acl/AclInternalsTest.php new file mode 100644 index 0000000..7d2b41c --- /dev/null +++ b/tests/Unit/Acl/AclInternalsTest.php @@ -0,0 +1,131 @@ +acl = new Tiger_Acl_Acl(); // the real shipped role graph (ini; DB tier is a no-op with no adapter) + } + + private function call(string $method, array $args = []) + { + return (new ReflectionMethod(Tiger_Acl_Acl::class, $method))->invokeArgs($this->acl, $args); + } + + #[Test] + public function apply_rule_walks_the_four_permission_verbs(): void + { + $this->acl->addResource(new Zend_Acl_Resource('Internals_Res')); + + // allow (privilege wildcard) → a blanket grant on the resource + $this->call('_applyRule', ['user', 'Internals_Res', '*', 'allow']); + $this->assertTrue($this->acl->isAllowed('user', 'Internals_Res', 'act')); + + // deny (specific privilege) → the more-specific deny overrides the blanket allow for `act` + $this->call('_applyRule', ['user', 'Internals_Res', 'act', 'deny']); + $this->assertFalse($this->acl->isAllowed('user', 'Internals_Res', 'act')); + + // removeDeny → drop the specific deny → the blanket allow shows through again + $this->call('_applyRule', ['user', 'Internals_Res', 'act', 'removeDeny']); + $this->assertTrue($this->acl->isAllowed('user', 'Internals_Res', 'act')); + + // removeAllow (wildcard) → drop the blanket grant → deny-by-default + $this->call('_applyRule', ['user', 'Internals_Res', '*', 'removeAllow']); + $this->assertFalse($this->acl->isAllowed('user', 'Internals_Res', 'act')); + } + + #[Test] + public function apply_rule_ignores_a_named_but_unregistered_role_or_resource(): void + { + // Unregistered resource → the rule is skipped (never registers it, never throws). + $this->call('_applyRule', ['user', 'Never_Registered', 'act', 'allow']); + $this->assertFalse($this->acl->has('Never_Registered')); + + // Unregistered role → skipped. + $this->acl->addResource(new Zend_Acl_Resource('Internals_Res2')); + $this->call('_applyRule', ['ghost-role', 'Internals_Res2', 'act', 'allow']); + $this->assertFalse($this->acl->isAllowed('user', 'Internals_Res2', 'act'), 'the skipped rule granted nothing'); + } + + #[Test] + public function apply_rule_normalizes_the_wildcard_tokens(): void + { + $this->acl->addResource(new Zend_Acl_Resource('Wild_Res')); + // '*'/'all'/'' all mean the Zend_Acl wildcard (null). A wildcard allow on the resource for any role. + $this->call('_applyRule', ['all', 'Wild_Res', '*', 'allow']); + $this->assertTrue($this->acl->isAllowed('guest', 'Wild_Res', 'anything'), 'a wildcard-role allow reaches guest'); + } + + #[Test] + public function register_resource_dedups_and_ignores_empties(): void + { + $this->call('_registerResource', ['']); // empty → no-op + $this->assertFalse($this->acl->has('')); + + $this->call('_registerResource', ['Dedup_Res']); + $this->call('_registerResource', ['Dedup_Res']); // second call short-circuits on has() + $this->assertTrue($this->acl->has('Dedup_Res')); + } + + #[Test] + public function topological_role_add_survives_a_cycle_and_a_missing_parent(): void + { + // A 2-node cycle plus a child whose declared parent doesn't exist — all must still be added + // (parentless as a fallback), and the loop must terminate. + $this->call('_addRolesTopologically', [[ + 'cyc_a' => ['cyc_b'], + 'cyc_b' => ['cyc_a'], + 'orphan' => ['no_such_parent'], + ]]); + + $this->assertTrue($this->acl->hasRole('cyc_a')); + $this->assertTrue($this->acl->hasRole('cyc_b')); + $this->assertTrue($this->acl->hasRole('orphan')); + } + + #[Test] + public function topological_role_add_respects_declared_parents_when_acyclic(): void + { + $this->call('_addRolesTopologically', [['newparent' => [], 'newchild' => ['newparent']]]); + + $this->assertTrue($this->acl->hasRole('newparent')); + $this->assertTrue($this->acl->hasRole('newchild')); + // the chain reflects the declared parent + $chain = $this->call('_roleChain', ['newchild']); + $this->assertContains('newparent', $chain); + } + + #[Test] + public function role_chain_handles_null_and_unknown_roles(): void + { + $this->assertSame([], $this->call('_roleChain', [null]), 'null role → empty chain'); + $this->assertSame(['zzz-unknown'], $this->call('_roleChain', ['zzz-unknown']), 'unknown role → itself only'); + + // A known role includes itself first, then its parents (admin descends from the lower tiers). + $chain = $this->call('_roleChain', ['admin']); + $this->assertSame('admin', $chain[0]); + $this->assertContains('user', $chain); + } +} diff --git a/tests/Unit/Admin/RegistryTest.php b/tests/Unit/Admin/RegistryTest.php new file mode 100644 index 0000000..9717396 --- /dev/null +++ b/tests/Unit/Admin/RegistryTest.php @@ -0,0 +1,219 @@ +clearAll(); + } + + protected function tearDown(): void + { + $this->clearAll(); + parent::tearDown(); + } + + private function clearAll(): void + { + Tiger_Admin_Nav::clear(); + Tiger_Admin_Settings::clear(); + Tiger_Admin_Header::clear(); + Tiger_Admin_UserMenu::clear(); + } + + /** Flip the discovery latch ON so items() won't glob real module *.ini files — pure isolation. */ + private function suppressDiscovery(string $class): void + { + (new ReflectionProperty($class, '_loaded'))->setValue(null, true); + } + + // ----- Tiger_Admin_Settings (no discovery — the simplest of the four) ---------------------- + + #[Test] + public function settings_register_applies_defaults_and_pages_returns_the_shaped_item(): void + { + Tiger_Admin_Settings::register(['key' => 'billing', 'label' => 'Billing', 'href' => '/billing/settings']); + $pages = Tiger_Admin_Settings::pages(); + + $this->assertCount(1, $pages); + $this->assertSame('billing', $pages[0]['key']); + $this->assertSame('/billing/settings', $pages[0]['href']); + $this->assertSame('/billing/settings', $pages[0]['match'], 'match defaults to href'); + $this->assertSame('fa-sliders', $pages[0]['icon'], 'default icon'); + $this->assertNull($pages[0]['resource']); + } + + #[Test] + public function settings_requires_key_label_and_href(): void + { + Tiger_Admin_Settings::register(['label' => 'No key', 'href' => '/x']); + Tiger_Admin_Settings::register(['key' => 'k', 'href' => '/x']); // no label + Tiger_Admin_Settings::register(['key' => 'k', 'label' => 'No href']); // no href + $this->assertSame([], Tiger_Admin_Settings::pages()); + } + + #[Test] + public function settings_dedupes_by_key_last_registration_wins(): void + { + Tiger_Admin_Settings::register(['key' => 'cms', 'label' => 'CMS', 'href' => '/cms/settings']); + Tiger_Admin_Settings::register(['key' => 'cms', 'label' => 'Content', 'href' => '/cms/settings']); + $pages = Tiger_Admin_Settings::pages(); + + $this->assertCount(1, $pages); + $this->assertSame('Content', $pages[0]['label']); + } + + #[Test] + public function settings_sorts_by_order_then_label(): void + { + Tiger_Admin_Settings::register(['key' => 'z', 'label' => 'Zed', 'href' => '/z', 'order' => 50]); + Tiger_Admin_Settings::register(['key' => 'a', 'label' => 'Alpha', 'href' => '/a', 'order' => 100]); + Tiger_Admin_Settings::register(['key' => 'b', 'label' => 'Beta', 'href' => '/b', 'order' => 100]); + + $keys = array_column(Tiger_Admin_Settings::pages(), 'key'); + // order 50 first; then order 100 broken by label ASC (Alpha before Beta). + $this->assertSame(['z', 'a', 'b'], $keys); + } + + // ----- Tiger_Admin_Nav (discovery-latch suppressed) ---------------------------------------- + + #[Test] + public function nav_register_shapes_the_item_with_defaults(): void + { + $this->suppressDiscovery(Tiger_Admin_Nav::class); + Tiger_Admin_Nav::register(['key' => 'help', 'label' => 'Help', 'href' => '/docs/admin/help']); + $items = Tiger_Admin_Nav::items(); + + $this->assertCount(1, $items); + $this->assertSame('help', $items[0]['key']); + $this->assertSame('fa-circle', $items[0]['icon']); + $this->assertSame('/docs/admin/help', $items[0]['match'], 'match defaults to href'); + $this->assertSame(100, $items[0]['order'], 'default order'); + } + + #[Test] + public function nav_requires_key_label_and_href(): void + { + $this->suppressDiscovery(Tiger_Admin_Nav::class); + Tiger_Admin_Nav::register(['label' => 'x', 'href' => '/x']); + Tiger_Admin_Nav::register(['key' => 'k', 'label' => 'x']); + $this->assertSame([], Tiger_Admin_Nav::items()); + } + + #[Test] + public function nav_sorts_by_order_then_label(): void + { + $this->suppressDiscovery(Tiger_Admin_Nav::class); + Tiger_Admin_Nav::register(['key' => 'a', 'label' => 'Apps', 'href' => '/a', 'order' => 90, 'resource' => 'A_C']); + Tiger_Admin_Nav::register(['key' => 'b', 'label' => 'Boards','href' => '/b', 'order' => 10]); + + $keys = array_column(Tiger_Admin_Nav::items(), 'key'); + $this->assertSame(['b', 'a'], $keys); + // the resource passes through untouched for the ACL filter downstream. + $this->assertSame('A_C', Tiger_Admin_Nav::items()[1]['resource']); + } + + #[Test] + public function nav_clear_also_resets_the_discovery_latch(): void + { + $this->suppressDiscovery(Tiger_Admin_Nav::class); + Tiger_Admin_Nav::register(['key' => 'x', 'label' => 'X', 'href' => '/x']); + $this->assertCount(1, Tiger_Admin_Nav::items()); + + Tiger_Admin_Nav::clear(); + // With the latch reset, items() runs discover() over the real module tree; assert it never + // fatals and that our cleared item is gone. + $items = Tiger_Admin_Nav::items(); + $this->assertIsArray($items); + $this->assertNotContains('x', array_column($items, 'key')); + } + + #[Test] + public function nav_discovery_reads_a_modules_navigation_ini_and_survives_a_dbless_boot(): void + { + // Latch OFF: items() runs the real discover(). tiger-core ships modules/analytics/configs/ + // navigation.ini, and there's no DB in a unit run — inactiveSlugs() throws and is swallowed + // (show everything), so the analytics nav item is discovered from config with zero code. + Tiger_Admin_Nav::clear(); + $keys = array_column(Tiger_Admin_Nav::items(), 'key'); + $this->assertContains('analytics', $keys, 'a module navigation.ini is auto-discovered'); + } + + // ----- Tiger_Admin_Header + Tiger_Admin_UserMenu (same shape) ------------------------------ + + #[Test] + public function header_register_items_shape_and_order(): void + { + $this->suppressDiscovery(Tiger_Admin_Header::class); + Tiger_Admin_Header::register(['key' => 'support', 'label' => 'Support', 'icon' => 'fa-life-ring', 'href' => '/support', 'order' => 50]); + Tiger_Admin_Header::register(['key' => 'new', 'label' => 'New', 'href' => '/new', 'order' => 10]); + Tiger_Admin_Header::register(['label' => 'bad']); // dropped — no key/href + + $items = Tiger_Admin_Header::items(); + $this->assertSame(['new', 'support'], array_column($items, 'key')); + $this->assertSame('fa-life-ring', $items[1]['icon']); + $this->assertSame('fa-circle', $items[0]['icon'], 'default icon'); + } + + #[Test] + public function header_discovery_runs_over_the_module_tree_without_fatal(): void + { + // Latch OFF: items() runs the real discover() (glob the module dirs, read each header.ini). + // No core module ships one, so the result is empty — but the discovery path itself must run + // and, with no DB, must not fatal (inactiveSlugs() throws → swallowed). + Tiger_Admin_Header::clear(); + $this->assertIsArray(Tiger_Admin_Header::items()); + } + + #[Test] + public function usermenu_discovery_runs_over_the_module_tree_without_fatal(): void + { + Tiger_Admin_UserMenu::clear(); + $this->assertIsArray(Tiger_Admin_UserMenu::items()); + } + + #[Test] + public function usermenu_register_items_shape_and_dedupe(): void + { + $this->suppressDiscovery(Tiger_Admin_UserMenu::class); + Tiger_Admin_UserMenu::register(['key' => 'billing', 'label' => 'Billing', 'href' => '/billing/account', 'resource' => 'Billing_AccountController']); + Tiger_Admin_UserMenu::register(['key' => 'billing', 'label' => 'Plan', 'href' => '/billing/plan']); // replaces by key + + $items = Tiger_Admin_UserMenu::items(); + $this->assertCount(1, $items); + $this->assertSame('Plan', $items[0]['label']); + $this->assertNull($items[0]['resource'], 'the replacing registration carried no resource'); + } +} diff --git a/tests/Unit/Agent/ContractTest.php b/tests/Unit/Agent/ContractTest.php new file mode 100644 index 0000000..738bcc7 --- /dev/null +++ b/tests/Unit/Agent/ContractTest.php @@ -0,0 +1,246 @@ + 'Creating the page.', + 'actions' => [['type' => 'api', 'module' => 'cms', 'service' => 'page', 'method' => 'save', 'params' => ['title' => 'FAQ'], 'reason' => 'r']], + 'navigate' => '/cms/admin/pages', + 'done' => false, + ]); + $c = Tiger_Agent_Contract::parse($raw); + $this->assertSame('Creating the page.', $c['say']); + $this->assertCount(1, $c['actions']); + $this->assertSame('api', $c['actions'][0]['type']); + $this->assertSame('/cms/admin/pages', $c['navigate']); + $this->assertFalse($c['done']); + } + + #[Test] + public function nonJsonReplyDegradesToASayOnlyDoneTurn(): void + { + $c = Tiger_Agent_Contract::parse(' Just chatting, no JSON here. '); + $this->assertSame('Just chatting, no JSON here.', $c['say']); + $this->assertSame([], $c['actions']); + $this->assertNull($c['navigate']); + $this->assertTrue($c['done']); // a chatty model still answers + } + + #[Test] + public function extractsJsonFromAFencedCodeBlock(): void + { + $raw = "Sure!\n```json\n{\"say\":\"Hi\",\"done\":true}\n```\nthanks"; + $c = Tiger_Agent_Contract::parse($raw); + $this->assertSame('Hi', $c['say']); + $this->assertTrue($c['done']); + } + + #[Test] + public function extractsTheFirstBalancedBraceBlockFromSurroundingProse(): void + { + $raw = 'Here you go: {"say":"With a nested {brace} and \"quote\"","done":false} — done.'; + $c = Tiger_Agent_Contract::parse($raw); + $this->assertSame('With a nested {brace} and "quote"', $c['say']); + $this->assertFalse($c['done']); + } + + #[Test] + public function doneDefaultsToTrueWhenTheKeyIsAbsent(): void + { + $c = Tiger_Agent_Contract::parse('{"say":"no done key"}'); + $this->assertTrue($c['done']); + } + + #[Test] + public function noBraceAtAllIsProse(): void + { + $c = Tiger_Agent_Contract::parse('absolutely no json'); + $this->assertSame('absolutely no json', $c['say']); + $this->assertTrue($c['done']); + } + + // ----- navigate validation ---------------------------------------------- + + #[Test] + public function navigateMustBeAPathAndRejectsSchemeHostAndDoubleSlash(): void + { + $this->assertNull(Tiger_Agent_Contract::parse('{"say":"","navigate":"https://evil.test/x"}')['navigate']); + $this->assertNull(Tiger_Agent_Contract::parse('{"say":"","navigate":"//evil.test"}')['navigate']); + $this->assertNull(Tiger_Agent_Contract::parse('{"say":"","navigate":"relative/path"}')['navigate']); + $this->assertSame('/ok/path', Tiger_Agent_Contract::parse('{"say":"","navigate":"/ok/path"}')['navigate']); + } + + // ----- isRead / isClient ------------------------------------------------ + + #[Test] + public function classifiesReadAndClientActionTypes(): void + { + $this->assertTrue(Tiger_Agent_Contract::isRead(Tiger_Agent_Contract::READ_FILE)); + $this->assertTrue(Tiger_Agent_Contract::isRead(Tiger_Agent_Contract::READ_INVENTORY)); + $this->assertFalse(Tiger_Agent_Contract::isRead(Tiger_Agent_Contract::ACTION_API)); + $this->assertFalse(Tiger_Agent_Contract::isRead(Tiger_Agent_Contract::DOM_READ)); + + $this->assertTrue(Tiger_Agent_Contract::isClient(Tiger_Agent_Contract::DOM_WRITE)); + $this->assertTrue(Tiger_Agent_Contract::isClient(Tiger_Agent_Contract::DOM_READ)); + $this->assertFalse(Tiger_Agent_Contract::isClient(Tiger_Agent_Contract::ACTION_FILE)); + } + + // ----- normalizeAction: fail-closed ------------------------------------- + + #[Test] + public function dropsActionsWithNoTypeOrAnUnknownType(): void + { + $this->assertNull(Tiger_Agent_Contract::normalizeAction(['reason' => 'no type'])); + $this->assertNull(Tiger_Agent_Contract::normalizeAction(['type' => 'hallucinated'])); + $this->assertNull(Tiger_Agent_Contract::normalizeAction('not even an array')); + } + + #[Test] + public function parseDropsUnrecognizedActionsButKeepsGoodOnes(): void + { + $raw = json_encode(['say' => 'x', 'actions' => [ + ['type' => 'nope'], + ['type' => 'api', 'module' => 'cms', 'service' => 'page', 'method' => 'save'], + ]]); + $c = Tiger_Agent_Contract::parse($raw); + $this->assertCount(1, $c['actions']); + $this->assertSame('api', $c['actions'][0]['type']); + } + + // ----- normalizeAction: api --------------------------------------------- + + #[Test] + public function apiActionRequiresModuleServiceMethodAndSanitizesThem(): void + { + $this->assertNull(Tiger_Agent_Contract::normalizeAction(['type' => 'api', 'module' => 'cms', 'service' => 'page'])); + + $a = Tiger_Agent_Contract::normalizeAction([ + 'type' => 'api', 'module' => 'c!m@s', 'service' => 'pa ge', 'method' => 'sa-ve_2', + 'params' => ['a' => 1], 'reason' => 'why', + ]); + $this->assertSame('cms', $a['module']); // non-alpha stripped + $this->assertSame('page', $a['service']); + $this->assertSame('save_2', $a['method']); // digits + underscore kept + $this->assertSame(['a' => 1], $a['params']); + $this->assertSame('why', $a['reason']); + } + + #[Test] + public function apiParamsDefaultToEmptyArrayWhenNotAnArray(): void + { + $a = Tiger_Agent_Contract::normalizeAction(['type' => 'api', 'module' => 'cms', 'service' => 'page', 'method' => 'save', 'params' => 'nope']); + $this->assertSame([], $a['params']); + } + + // ----- normalizeAction: code -------------------------------------------- + + #[Test] + public function codeActionRequiresNonEmptyCodeAndDefaultsNameLanguage(): void + { + $this->assertNull(Tiger_Agent_Contract::normalizeAction(['type' => 'code', 'code' => ''])); + + $a = Tiger_Agent_Contract::normalizeAction(['type' => 'code', 'code' => 'assertSame('agent-snippet', $a['name']); + $this->assertSame('php', $a['language']); + $this->assertSame('assertNull(Tiger_Agent_Contract::normalizeAction(['type' => 'file', 'path' => 'x'])); // no contents key + $this->assertNull(Tiger_Agent_Contract::normalizeAction(['type' => 'file', 'contents' => 'x'])); // no path + + $a = Tiger_Agent_Contract::normalizeAction(['type' => 'file', 'path' => 'modules/x/v.phtml', 'contents' => '

hi

', 'reason' => 'r']); + $this->assertSame('modules/x/v.phtml', $a['path']); + $this->assertSame('

hi

', $a['contents']); + } + + #[Test] + public function fileActionAcceptsEmptyStringContents(): void + { + $a = Tiger_Agent_Contract::normalizeAction(['type' => 'file', 'path' => 'x.txt', 'contents' => '']); + $this->assertSame('', $a['contents']); // empty is valid; only a MISSING contents key is refused + } + + // ----- normalizeAction: module ------------------------------------------ + + #[Test] + public function moduleSlugIsNormalizedToTheGeneratorGrammar(): void + { + $this->assertNull(Tiger_Agent_Contract::normalizeAction(['type' => 'module'])); // no name + $this->assertNull(Tiger_Agent_Contract::normalizeAction(['type' => 'module', 'name' => '123'])); // becomes empty after strip + + $a = Tiger_Agent_Contract::normalizeAction(['type' => 'module', 'name' => 'Book-Store 2']); + $this->assertSame('bookstore2', $a['name']); // lowercased, hyphen/space stripped (trailing digit kept) + + // A leading digit run is stripped (a slug must start with a letter). + $this->assertSame('books', Tiger_Agent_Contract::normalizeAction(['type' => 'module', 'name' => '42books'])['name']); + } + + // ----- normalizeAction: read.* ------------------------------------------ + + #[Test] + public function readInventoryAndTreeAndGuideNormalize(): void + { + $this->assertSame(['type' => 'read.inventory', 'reason' => ''], Tiger_Agent_Contract::normalizeAction(['type' => 'read.inventory'])); + + $tree = Tiger_Agent_Contract::normalizeAction(['type' => 'read.tree', 'path' => 'a/b']); + $this->assertSame('a/b', $tree['path']); + + // read.guide sanitizes the module slug and is valid even with no module (platform conventions). + $guide = Tiger_Agent_Contract::normalizeAction(['type' => 'read.guide', 'module' => 'C-M/S']); + $this->assertSame('cms', $guide['module']); + $this->assertSame('', Tiger_Agent_Contract::normalizeAction(['type' => 'read.guide'])['module']); + } + + #[Test] + public function readFileAndGrepRequireTheirCoreField(): void + { + $this->assertNull(Tiger_Agent_Contract::normalizeAction(['type' => 'read.file'])); + $this->assertSame('a.php', Tiger_Agent_Contract::normalizeAction(['type' => 'read.file', 'path' => 'a.php'])['path']); + + $this->assertNull(Tiger_Agent_Contract::normalizeAction(['type' => 'read.grep'])); + $grep = Tiger_Agent_Contract::normalizeAction(['type' => 'read.grep', 'query' => 'foo', 'path' => 'lib']); + $this->assertSame('foo', $grep['query']); + $this->assertSame('lib', $grep['path']); + } + + // ----- normalizeAction: dom.* ------------------------------------------- + + #[Test] + public function domReadRequiresTargetAndDomWriteRequiresTargetPlusValue(): void + { + $this->assertNull(Tiger_Agent_Contract::normalizeAction(['type' => 'dom.read'])); + $this->assertSame('body', Tiger_Agent_Contract::normalizeAction(['type' => 'dom.read', 'target' => 'body'])['target']); + + $this->assertNull(Tiger_Agent_Contract::normalizeAction(['type' => 'dom.write', 'target' => 'body'])); // no value + $w = Tiger_Agent_Contract::normalizeAction(['type' => 'dom.write', 'target' => 'body', 'value' => 'x', 'kind' => 'html']); + $this->assertSame('x', $w['value']); + $this->assertSame('html', $w['kind']); + } +} diff --git a/tests/Unit/Agent/ForgeUnitTest.php b/tests/Unit/Agent/ForgeUnitTest.php new file mode 100644 index 0000000..2c9ef27 --- /dev/null +++ b/tests/Unit/Agent/ForgeUnitTest.php @@ -0,0 +1,103 @@ +_resolveModulePath($p); } + public function minimalScaffold(string $base, string $name): void { $this->_minimalScaffold($base, $name); } +} + +/** + * Tiger_Agent_Forge — the mode/tier ranking that raises the approval threshold without raising the + * ACL ceiling, and the write sandbox resolver that keeps a proposed file path inside + * application/modules (so core, the framework, and the agent itself are unreachable by construction). + * Both are pure; the ACL-gated execute() paths are covered by the integration test. + */ +#[CoversClass(Tiger_Agent_Forge::class)] +final class ForgeUnitTest extends UnitTestCase +{ + // ----- autoRank: the tier dial ------------------------------------------ + + #[Test] + public function autoRankMarksApiReadsAlwaysAutoAndApiWritesAutoTier(): void + { + $this->assertSame(-1, Tiger_Agent_Forge::autoRank(['type' => 'api', 'method' => 'list'])); + $this->assertSame(-1, Tiger_Agent_Forge::autoRank(['type' => 'api', 'method' => 'DataTable'])); // case-insensitive + $this->assertSame(0, Tiger_Agent_Forge::autoRank(['type' => 'api', 'method' => 'save'])); + $this->assertSame(0, Tiger_Agent_Forge::autoRank(['type' => 'api', 'method' => 'delete'])); + } + + #[Test] + public function autoRankPutsTheSharpTiersAtYoloOnly(): void + { + $this->assertSame(1, Tiger_Agent_Forge::autoRank(['type' => 'code'])); + $this->assertSame(1, Tiger_Agent_Forge::autoRank(['type' => 'file'])); + $this->assertSame(1, Tiger_Agent_Forge::autoRank(['type' => 'module'])); + $this->assertSame(-1, Tiger_Agent_Forge::autoRank(['type' => 'read.file'])); // unknown/other → always + } + + // ----- _resolveModulePath: the write sandbox ---------------------------- + + #[Test] + public function resolveKeepsAValidPathInsideTheModulesRoot(): void + { + $forge = new ExposedForge('superadmin'); + $abs = $forge->resolve('agent/views/scripts/index/x.phtml'); + $this->assertNotNull($abs); + $this->assertStringStartsWith(realpath(MODULES_PATH), $abs); + } + + #[Test] + public function resolveToleratesALeadingModulesOrApplicationModulesPrefix(): void + { + $forge = new ExposedForge('superadmin'); + $this->assertNotNull($forge->resolve('modules/agent/x.phtml')); + $this->assertNotNull($forge->resolve('application/modules/agent/x.phtml')); + } + + #[Test] + public function resolveRefusesTraversalEmptyAndNullByte(): void + { + $forge = new ExposedForge('superadmin'); + $this->assertNull($forge->resolve('../../etc/passwd')); + $this->assertNull($forge->resolve('agent/../../escape.php')); + $this->assertNull($forge->resolve('')); + $this->assertNull($forge->resolve("agent/x\0.php")); + } + + // ----- _minimalScaffold: the no-generator fallback skeleton -------------- + + #[Test] + public function minimalScaffoldLaysDownAnActivatableModuleSkeleton(): void + { + $base = sys_get_temp_dir() . '/tiger-scaffold-' . uniqid(); + (new ExposedForge('developer'))->minimalScaffold($base, 'bookshop'); + + $this->assertFileExists($base . '/Bootstrap.php'); + $this->assertFileExists($base . '/controllers/IndexController.php'); + $this->assertFileExists($base . '/views/scripts/index/index.phtml'); + $this->assertFileExists($base . '/configs/acl.ini'); + $this->assertStringContainsString('Bookshop_IndexController', file_get_contents($base . '/controllers/IndexController.php')); + $this->assertStringContainsString('Bookshop_Bootstrap', file_get_contents($base . '/Bootstrap.php')); + + // clean up the temp tree + foreach (['configs/acl.ini', 'views/scripts/index/index.phtml', 'controllers/IndexController.php', 'Bootstrap.php'] as $f) { @unlink($base . '/' . $f); } + foreach (['views/scripts/index', 'views/scripts', 'views', 'controllers', 'configs', ''] as $d) { @rmdir($base . '/' . $d); } + } +} diff --git a/tests/Unit/Agent/LoopHelpersTest.php b/tests/Unit/Agent/LoopHelpersTest.php new file mode 100644 index 0000000..5250fc3 --- /dev/null +++ b/tests/Unit/Agent/LoopHelpersTest.php @@ -0,0 +1,145 @@ +_transcriptToNeutral($t); } + public function enrich(array &$w, $text, array $ctx, $mode): void { $this->_enrichLastUserTurn($w, $text, $ctx, $mode); } + public function feedback(array $e): string { return $this->_feedbackText($e); } + public function strip(array $l): array { return $this->_strip($l); } + public function clientEntry(array $a): array { return $this->_clientEntry($a); } + public function attachImages(array &$w, $conv, array $a): void { $this->_attachImages($w, $conv, $a); } +} + +/** + * Tiger_Agent_Loop — the pure turn-shaping helpers that surround the (network/DB) step engine: + * transcript → provider-neutral messages, the context-enriched trailing user envelope, the model- + * facing tool-results block (including the capped /api data bridge), the client (DOM) ledger entry, + * the ledger strip, mode ranking, and the vision-guard that skips image bytes for a text-only model. + * The live `_converse` loop (provider + DB models) is the documented network boundary, not covered. + */ +#[CoversClass(Tiger_Agent_Loop::class)] +final class LoopHelpersTest extends UnitTestCase +{ + private ExposedLoop $loop; + + protected function setUp(): void + { + parent::setUp(); + $this->loop = new ExposedLoop('developer', 'user-1', 'org-1'); + } + + #[Test] + public function modeRankOrdersAskAutoYoloAndDefaultsUnknownToAsk(): void + { + $this->assertSame(0, Tiger_Agent_Loop::modeRank('ask')); + $this->assertSame(1, Tiger_Agent_Loop::modeRank('auto')); + $this->assertSame(2, Tiger_Agent_Loop::modeRank('yolo')); + $this->assertSame(0, Tiger_Agent_Loop::modeRank('bogus')); + } + + #[Test] + public function transcriptToNeutralMapsRolesAndDropsEmptyTurns(): void + { + // Literal role strings (== Tiger_Model_AgentMessage::ROLE_USER/ROLE_ASSISTANT) so the DB model + // class isn't autoloaded here — it carries a pre-existing PHP 8.5 implicit-nullable deprecation. + $neutral = $this->loop->toNeutral([ + ['role' => 'user', 'content' => 'hello'], + ['role' => 'assistant', 'content' => 'hi there'], + ['role' => 'assistant', 'content' => ''], // empty → dropped + ]); + $this->assertCount(2, $neutral); + $this->assertSame('user', $neutral[0]['role']); + $this->assertSame('assistant', $neutral[1]['role']); + } + + #[Test] + public function enrichLastUserTurnReplacesTheTrailingUserWithAContextEnvelope(): void + { + $working = [['role' => 'user', 'content' => 'raw text']]; + $this->loop->enrich($working, 'Add an FAQ page', ['path' => '/x', 'role' => 'admin'], 'auto'); + + $decoded = json_decode($working[0]['content'], true); + $this->assertSame('Add an FAQ page', $decoded['message']); + $this->assertSame('/x', $decoded['context']['path']); + $this->assertSame('auto', $decoded['context']['mode']); + } + + #[Test] + public function enrichAppendsAnEnvelopeWhenNoTrailingUserTurnExists(): void + { + $working = [['role' => 'assistant', 'content' => 'earlier']]; + $this->loop->enrich($working, 'hi', [], 'ask'); + $this->assertSame('assistant', $working[0]['role']); + $this->assertSame('user', $working[1]['role']); + $this->assertStringContainsString('"message": "hi"', $working[1]['content']); + } + + #[Test] + public function feedbackTextRendersScoutFeedbackAndBridgesApiDataCapped(): void + { + $block = $this->loop->feedback([ + ['type' => 'read.file', 'status' => 'done', 'summary' => 'Read x', 'feedback' => 'FILE CONTENTS HERE'], + ['type' => 'api', 'status' => 'done', 'summary' => 'Called cms/page/save', 'detail' => ['data' => ['id' => 'new-id']]], + ]); + $this->assertStringContainsString('[tool results', $block); + $this->assertStringContainsString('FILE CONTENTS HERE', $block); + $this->assertStringContainsString('Data: ', $block); + $this->assertStringContainsString('new-id', $block); + } + + #[Test] + public function feedbackTextTruncatesAnOversizedApiPayload(): void + { + $big = ['rows' => array_fill(0, 5000, 'x')]; + $block = $this->loop->feedback([['type' => 'api', 'status' => 'done', 'summary' => 's', 'detail' => ['data' => $big]]]); + $this->assertStringContainsString('(truncated)', $block); + } + + #[Test] + public function stripDropsTheHeavyFeedbackPayloadFromEachLedgerEntry(): void + { + $stripped = $this->loop->strip([ + ['type' => 'read.file', 'summary' => 's', 'feedback' => 'HUGE'], + ['type' => 'api', 'summary' => 's2'], + ]); + $this->assertArrayNotHasKey('feedback', $stripped[0]); + $this->assertArrayNotHasKey('feedback', $stripped[1]); + $this->assertSame('s', $stripped[0]['summary']); + } + + #[Test] + public function clientEntryDescribesADomActionForTheBrowserToRun(): void + { + $entry = $this->loop->clientEntry(['type' => 'dom.write', 'target' => 'articleBody', 'value' => 'x', 'kind' => 'html', 'reason' => 'improve']); + $this->assertSame('client', $entry['status']); + $this->assertSame('dom.write', $entry['type']); + $this->assertStringContainsString('articleBody', $entry['summary']); + $this->assertSame('x', $entry['action']['value']); + + $read = $this->loop->clientEntry(['type' => 'dom.read', 'target' => 'headline']); + $this->assertStringStartsWith('Read', $read['summary']); + } + + #[Test] + public function attachImagesSkipsWhenTheModelIsNotMultimodal(): void + { + // grok-2-latest is text-only per the vision heuristic → image bytes are never attached + // (and no media disk is touched), so a text-only turn never fails on an unreadable image. + $working = [['role' => 'user', 'content' => 'look at this']]; + $conv = (object) ['provider' => 'grok', 'model' => 'grok-2-latest']; + $this->loop->attachImages($working, $conv, [ + ['kind' => 'image', 'storage_key' => 'k', 'disk' => 'local', 'mime' => 'image/png'], + ]); + $this->assertArrayNotHasKey('images', $working[0]); + } +} diff --git a/tests/Unit/Agent/ProviderFactoryTest.php b/tests/Unit/Agent/ProviderFactoryTest.php new file mode 100644 index 0000000..4ac2355 --- /dev/null +++ b/tests/Unit/Agent/ProviderFactoryTest.php @@ -0,0 +1,104 @@ +assertInstanceOf(Tiger_Agent_Provider_Anthropic::class, Tiger_Agent_Provider_Factory::make('anthropic')); + $this->assertInstanceOf(Tiger_Agent_Provider_OpenAi::class, Tiger_Agent_Provider_Factory::make('openai')); + $this->assertInstanceOf(Tiger_Agent_Provider_Gemini::class, Tiger_Agent_Provider_Factory::make('gemini')); + } + + #[Test] + public function makeFallsBackToAnthropicForAnUnknownKey(): void + { + $this->assertInstanceOf(Tiger_Agent_Provider_Anthropic::class, Tiger_Agent_Provider_Factory::make('does-not-exist')); + } + + #[Test] + public function defaultModelIsTheProvidersDefaultAndFallsBackForUnknown(): void + { + $this->assertSame('claude-sonnet-5', Tiger_Agent_Provider_Factory::defaultModel('anthropic')); + $this->assertSame('gpt-4o', Tiger_Agent_Provider_Factory::defaultModel('openai')); + // unknown → anthropic's default + $this->assertSame('claude-sonnet-5', Tiger_Agent_Provider_Factory::defaultModel('nope')); + } + + #[Test] + public function staticModelsReturnsTheCuratedFallbackList(): void + { + $m = Tiger_Agent_Provider_Factory::staticModels('deepseek'); + $this->assertContains('deepseek-chat', $m); + $this->assertContains('deepseek-reasoner', $m); + // unknown key → anthropic's list + $this->assertContains('claude-sonnet-5', Tiger_Agent_Provider_Factory::staticModels('nope')); + } + + #[Test] + public function optionsIsTheKeyToLabelRosterForTheDropdown(): void + { + $opts = Tiger_Agent_Provider_Factory::options(); + $this->assertSame('Anthropic (Claude)', $opts['anthropic']); + $this->assertArrayHasKey('openrouter', $opts); + $this->assertCount(8, $opts); // the full roster + } + + // ----- supportsVision: the conservative heuristic ----------------------- + + /** @return array */ + public static function visionCases(): array + { + return [ + 'claude is multimodal' => ['anthropic', 'claude-opus-4-8', true], + 'non-claude anthropic id' => ['anthropic', 'palm-2', false], + 'gpt-4o sees' => ['openai', 'gpt-4o', true], + 'gpt-4.1 sees' => ['openai', 'gpt-4.1-mini', true], + 'o3 sees' => ['openai', 'o3-mini', true], + 'gpt-4o-audio does not' => ['openai', 'gpt-4o-audio-preview', false], + 'gpt-4o-realtime does not' => ['openai', 'gpt-4o-realtime-preview', false], + 'plain gpt-3.5 does not' => ['openai', 'gpt-3.5-turbo', false], + 'gemini is multimodal' => ['gemini', 'gemini-2.0-flash', true], + 'non-gemini id' => ['gemini', 'palm', false], + 'grok vision variant' => ['grok', 'grok-2-vision-latest', true], + 'grok-4 sees' => ['grok', 'grok-4', true], + 'plain grok-2 does not' => ['grok', 'grok-2-latest', false], + 'pixtral sees' => ['mistral', 'pixtral-12b', true], + 'mistral-small-24 sees' => ['mistral', 'mistral-small-2409', true], + 'plain mistral does not' => ['mistral', 'mistral-large-latest', false], + 'openrouter claude route sees' => ['openrouter', 'anthropic/claude-sonnet-5', true], + 'openrouter llama-3.2 sees' => ['openrouter', 'meta-llama/llama-3.2-11b-vision', true], + 'openrouter plain text route' => ['openrouter', 'meta-llama/llama-3.1-8b', false], + 'groq vision sees' => ['groq', 'llama-3.2-11b-vision-preview', true], + 'groq plain text does not' => ['groq', 'llama-3.3-70b-versatile', false], + 'deepseek is text-only' => ['deepseek', 'deepseek-chat', false], + 'unknown provider defaults off' => ['whoknows', 'anything', false], + ]; + } + + #[Test] + #[DataProvider('visionCases')] + public function supportsVisionMatchesTheHeuristic(string $provider, string $model, bool $expected): void + { + $this->assertSame($expected, Tiger_Agent_Provider_Factory::supportsVision($provider, $model)); + } +} diff --git a/tests/Unit/Agent/ProviderPayloadTest.php b/tests/Unit/Agent/ProviderPayloadTest.php new file mode 100644 index 0000000..d6cd200 --- /dev/null +++ b/tests/Unit/Agent/ProviderPayloadTest.php @@ -0,0 +1,287 @@ +_payload($s, $m, $model); } + public function mapMessages(array $m): array { return $this->_mapMessages($m); } + protected function _post(array $payload, $apiKey) { + $this->lastPayload = $payload; + return [ + 'content' => [['type' => 'text', 'text' => 'Hello'], ['type' => 'thinking', 'text' => 'ignored']], + 'usage' => ['input_tokens' => 10, 'cache_read_input_tokens' => 2, 'cache_creation_input_tokens' => 3, 'output_tokens' => 7], + ]; + } + }; + } + + #[Test] + public function anthropicCompleteConcatenatesTextBlocksAndSumsCachedInputTokens(): void + { + $a = $this->anthropic(); + $res = $a->complete('SYS', [['role' => 'user', 'content' => 'hi']], 'claude-sonnet-5', 'key'); + $this->assertSame('Hello', $res['text']); // only text blocks, thinking dropped + $this->assertSame(15, $res['usage']['input']); // 10 + 2 + 3 + $this->assertSame(7, $res['usage']['output']); + } + + #[Test] + public function anthropicPayloadMarksTheSystemPromptEphemeralForPromptCaching(): void + { + $p = $this->anthropic()->payload('SYSTEM', [['role' => 'user', 'content' => 'hi']], 'claude-sonnet-5'); + $this->assertSame('claude-sonnet-5', $p['model']); + $this->assertSame(4096, $p['max_tokens']); + $this->assertSame('SYSTEM', $p['system'][0]['text']); + $this->assertSame('ephemeral', $p['system'][0]['cache_control']['type']); + $this->assertNotEmpty($p['messages']); + } + + #[Test] + public function anthropicMapMessagesPrependsAUserTurnMergesRolesAndRendersImages(): void + { + $mapped = $this->anthropic()->mapMessages([ + ['role' => 'assistant', 'content' => 'leading assistant'], // forces a (continue) user prepend + ['role' => 'user', 'content' => 'look', 'images' => [['mime' => 'image/png', 'data' => 'AAAA']]], + ['role' => 'user', 'content' => 'and more'], // merges into the previous user turn + ['role' => 'user', 'content' => ''], // empty + no image → skipped + ['role' => 'assistant', 'content' => 'x', 'images' => [['mime' => 'image/png', 'data' => 'IGNORED']]], + ]); + + $this->assertSame('user', $mapped[0]['role']); + $this->assertSame('(continue)', $mapped[0]['content'][0]['text']); + + // The image ended up as a base64 image block on a user turn. + $flat = json_encode($mapped); + $this->assertStringContainsString('"type":"image"', $flat); + $this->assertStringContainsString('"data":"AAAA"', $flat); + // An assistant turn never carries an image. + $this->assertStringNotContainsString('IGNORED', $flat); + } + + #[Test] + public function anthropicModelsWithoutAKeyReturnsTheStaticFallbackShape(): void + { + $models = (new Tiger_Agent_Provider_Anthropic())->models(''); + $this->assertNotEmpty($models); + $this->assertArrayHasKey('id', $models[0]); + $this->assertArrayHasKey('label', $models[0]); + } + + // ===== OpenAI-compatible base ============================================ + + /** An OpenAI-compatible stub that captures the outgoing request and returns a canned reply. */ + private function openAiStub(): Tiger_Agent_Provider_OpenAiCompatible + { + return new class extends Tiger_Agent_Provider_OpenAi { + public array $captured = []; + protected function _post($url, array $payload, array $headers) { + $this->captured = ['url' => $url, 'payload' => $payload, 'headers' => $headers]; + return ['choices' => [['message' => ['content' => 'reply text']]], 'usage' => ['prompt_tokens' => 4, 'completion_tokens' => 6]]; + } + }; + } + + #[Test] + public function openAiCompatibleBuildsSystemPlusTurnsAndParsesTheReply(): void + { + $stub = $this->openAiStub(); + $res = $stub->complete('SYS', [ + ['role' => 'user', 'content' => 'question'], + ['role' => 'assistant', 'content' => 'earlier answer'], + ['role' => 'user', 'content' => ''], // empty + no image → skipped + ], 'gpt-4o', 'key'); + + $this->assertSame('reply text', $res['text']); + $this->assertSame(4, $res['usage']['input']); + $this->assertSame(6, $res['usage']['output']); + + $turns = $stub->captured['payload']['messages']; + $this->assertSame('system', $turns[0]['role']); + $this->assertSame('SYS', $turns[0]['content']); + $this->assertSame('user', $turns[1]['role']); + $this->assertSame('assistant', $turns[2]['role']); + $this->assertStringEndsWith('/chat/completions', $stub->captured['url']); + } + + #[Test] + public function openAiUsesMaxCompletionTokensNotMaxTokens(): void + { + $stub = $this->openAiStub(); + $stub->complete('', [['role' => 'user', 'content' => 'hi']], 'gpt-5', 'key'); + $this->assertArrayHasKey('max_completion_tokens', $stub->captured['payload']); + $this->assertArrayNotHasKey('max_tokens', $stub->captured['payload']); + // No system turn when the system prompt is empty. + $this->assertSame('user', $stub->captured['payload']['messages'][0]['role']); + } + + #[Test] + public function openAiCompatibleRendersAUserImageAsAVisionImageUrlPart(): void + { + $stub = $this->openAiStub(); + $stub->complete('SYS', [ + ['role' => 'user', 'content' => 'see this', 'images' => [['mime' => 'image/jpeg', 'data' => 'ZZZ']]], + ], 'gpt-4o', 'key'); + + $parts = $stub->captured['payload']['messages'][1]['content']; // [0]=system, [1]=user + $this->assertSame('text', $parts[0]['type']); + $this->assertSame('image_url', $parts[1]['type']); + $this->assertSame('data:image/jpeg;base64,ZZZ', $parts[1]['image_url']['url']); + } + + #[Test] + public function openRouterAddsAttributionHeaders(): void + { + $stub = new class extends Tiger_Agent_Provider_OpenRouter { + public array $headers = []; + protected function _post($url, array $payload, array $headers) { + $this->headers = $headers; + return ['choices' => [['message' => ['content' => 'x']]], 'usage' => []]; + } + }; + $stub->complete('', [['role' => 'user', 'content' => 'hi']], 'openai/gpt-4o-mini', 'key'); + $joined = implode("\n", $stub->headers); + $this->assertStringContainsString('HTTP-Referer: https://webtigers.com', $joined); + $this->assertStringContainsString('X-Title: Tiger', $joined); + $this->assertStringContainsString('Authorization: Bearer key', $joined); + } + + #[Test] + public function everyConcreteOpenAiProviderTargetsItsOwnBaseUrl(): void + { + $cases = [ + [new class extends Tiger_Agent_Provider_Grok { public string $u = ''; protected function _post($url, array $p, array $h) { $this->u = $url; return ['choices' => [['message' => ['content' => '']]], 'usage' => []]; } }, 'api.x.ai'], + [new class extends Tiger_Agent_Provider_Groq { public string $u = ''; protected function _post($url, array $p, array $h) { $this->u = $url; return ['choices' => [['message' => ['content' => '']]], 'usage' => []]; } }, 'api.groq.com'], + [new class extends Tiger_Agent_Provider_Mistral { public string $u = ''; protected function _post($url, array $p, array $h) { $this->u = $url; return ['choices' => [['message' => ['content' => '']]], 'usage' => []]; } }, 'api.mistral.ai'], + [new class extends Tiger_Agent_Provider_DeepSeek { public string $u = ''; protected function _post($url, array $p, array $h) { $this->u = $url; return ['choices' => [['message' => ['content' => '']]], 'usage' => []]; } }, 'api.deepseek.com'], + ]; + foreach ($cases as [$stub, $host]) { + $stub->complete('', [['role' => 'user', 'content' => 'hi']], 'm', 'k'); + $this->assertStringContainsString($host, $stub->u); + $this->assertStringEndsWith('/chat/completions', $stub->u); + } + } + + #[Test] + public function everyConcreteOpenAiProviderReturnsItsKeylessStaticModelList(): void + { + foreach ([ + new Tiger_Agent_Provider_OpenAi(), + new Tiger_Agent_Provider_OpenRouter(), + new Tiger_Agent_Provider_Grok(), + new Tiger_Agent_Provider_Groq(), + new Tiger_Agent_Provider_Mistral(), + new Tiger_Agent_Provider_DeepSeek(), + ] as $adapter) { + $models = $adapter->models(''); // no key → the curated static fallback (no network) + $this->assertNotEmpty($models, get_class($adapter) . ' should have static models'); + $this->assertSame($models[0]['id'], $models[0]['label']); + } + } + + // ===== Gemini ============================================================ + + private function geminiStub(): Tiger_Agent_Provider_Gemini + { + return new class extends Tiger_Agent_Provider_Gemini { + public array $captured = []; + public function contents(array $m): array { return $this->_contents($m); } + protected function _post($url, array $payload, $apiKey) { + $this->captured = ['url' => $url, 'payload' => $payload]; + return [ + 'candidates' => [['content' => ['parts' => [['text' => 'A'], ['text' => 'B']]]]], + 'usageMetadata' => ['promptTokenCount' => 11, 'candidatesTokenCount' => 22], + ]; + } + }; + } + + #[Test] + public function geminiNormalizesTheModelIdSetsSystemInstructionAndParsesParts(): void + { + $stub = $this->geminiStub(); + $res = $stub->complete('SYS', [['role' => 'user', 'content' => 'hi']], 'models/gemini-2.0-flash', 'key'); + + $this->assertSame('AB', $res['text']); // parts concatenated + $this->assertSame(11, $res['usage']['input']); + $this->assertSame(22, $res['usage']['output']); + + // 'models/' prefix stripped, then re-added by the endpoint builder → single occurrence. + $this->assertStringContainsString('models/gemini-2.0-flash:generateContent', $stub->captured['url']); + $this->assertSame('SYS', $stub->captured['payload']['systemInstruction']['parts'][0]['text']); + $this->assertSame(4096, $stub->captured['payload']['generationConfig']['maxOutputTokens']); + } + + #[Test] + public function geminiOmitsSystemInstructionWhenSystemIsEmpty(): void + { + $stub = $this->geminiStub(); + $stub->complete('', [['role' => 'user', 'content' => 'hi']], 'gemini-2.0-flash', 'key'); + $this->assertArrayNotHasKey('systemInstruction', $stub->captured['payload']); + } + + #[Test] + public function geminiContentsUsesModelRoleMergesTurnsRendersInlineImagesAndLeadsWithUser(): void + { + $contents = $this->geminiStub()->contents([ + ['role' => 'assistant', 'content' => 'lead'], // → prepend a user (continue) + ['role' => 'user', 'content' => 'look', 'images' => [['mime' => 'image/png', 'data' => 'IMG']]], + ['role' => 'user', 'content' => 'more'], // merges + ]); + + $this->assertSame('user', $contents[0]['role']); + $flat = json_encode($contents); + $this->assertStringContainsString('"role":"model"', $flat); // assistant → model + $this->assertStringContainsString('"inlineData"', $flat); + $this->assertStringContainsString('"data":"IMG"', $flat); + } + + #[Test] + public function geminiModelsWithoutAKeyIsTheStaticFallback(): void + { + $models = (new Tiger_Agent_Provider_Gemini())->models(''); + $this->assertNotEmpty($models); + $this->assertArrayHasKey('id', $models[0]); + } +} diff --git a/tests/Unit/Agent/ToolsPromptTest.php b/tests/Unit/Agent/ToolsPromptTest.php new file mode 100644 index 0000000..ebfc437 --- /dev/null +++ b/tests/Unit/Agent/ToolsPromptTest.php @@ -0,0 +1,99 @@ +assertStringContainsString('role is', $prompt); + $this->assertStringContainsString('"manager"', $prompt); + $this->assertStringContainsString('(no callable services for this role)', $prompt); + $this->assertStringContainsString('answer questions and guide the user', $prompt); // no caps → default line + } + + #[Test] + public function capabilitiesLineListsEachUnlockedTier(): void + { + $prompt = Tiger_Agent_Tools::systemPrompt('developer', [ + 'inventory' => true, 'read' => true, 'dom' => true, 'api' => true, + 'code' => true, 'file' => true, 'module' => true, + ], [], 'ask'); + $this->assertStringContainsString('inspect the system (inventory)', $prompt); + $this->assertStringContainsString('call /api services', $prompt); + $this->assertStringContainsString('author executable PHP snippets', $prompt); + $this->assertStringContainsString('scaffold new modules', $prompt); + } + + #[Test] + public function readToolBlockAppearsOnlyWithTheReadCapability(): void + { + $without = Tiger_Agent_Tools::systemPrompt('manager', ['api' => true], [], 'ask'); + $this->assertStringNotContainsString('LOOK BEFORE YOU LEAP', $without); + + $with = Tiger_Agent_Tools::systemPrompt('superadmin', ['read' => true, 'inventory' => true], [], 'ask'); + $this->assertStringContainsString('LOOK BEFORE YOU LEAP', $with); + $this->assertStringContainsString('read.inventory', $with); + $this->assertStringContainsString('read.grep', $with); + } + + #[Test] + public function domBlockIsAdvertisedOnlyWhenThePageDeclaresTargets(): void + { + // dom cap but no targets → no block + $noTargets = Tiger_Agent_Tools::systemPrompt('manager', ['dom' => true], [], 'ask'); + $this->assertStringNotContainsString('EDITABLE TARGETS', $noTargets); + + $withTargets = Tiger_Agent_Tools::systemPrompt('manager', ['dom' => true], [ + 'targets' => [ + ['name' => 'articleBody', 'kind' => 'html', 'label' => 'Article body'], + ['name' => 'headline', 'kind' => 'text'], + ['bogus' => 'no name — skipped'], + ], + ], 'ask'); + $this->assertStringContainsString('EDITABLE TARGETS', $withTargets); + $this->assertStringContainsString('articleBody [html] Article body', $withTargets); + $this->assertStringContainsString('headline [text]', $withTargets); + } + + #[Test] + public function a11yBlockAppearsOnlyWithTheFileCapability(): void + { + $this->assertStringNotContainsString('ACCESSIBILITY (TigerAlly)', Tiger_Agent_Tools::systemPrompt('manager', ['api' => true], [], 'ask')); + $this->assertStringContainsString('ACCESSIBILITY (TigerAlly)', Tiger_Agent_Tools::systemPrompt('superadmin', ['file' => true], [], 'ask')); + } + + #[Test] + public function eachModeEmitsItsOwnModeLineAndAnUnknownModeFallsBackToAsk(): void + { + $this->assertStringContainsString('ASK —', Tiger_Agent_Tools::systemPrompt('manager', [], [], 'ask')); + $this->assertStringContainsString('AUTO —', Tiger_Agent_Tools::systemPrompt('manager', [], [], 'auto')); + $this->assertStringContainsString('YOLO —', Tiger_Agent_Tools::systemPrompt('manager', [], [], 'yolo')); + $this->assertStringContainsString('ASK —', Tiger_Agent_Tools::systemPrompt('manager', [], [], 'nonsense')); + } + + #[Test] + public function theCurrentPagePathIsWovenIntoThePrompt(): void + { + $prompt = Tiger_Agent_Tools::systemPrompt('manager', ['api' => true], ['path' => '/cms/admin/pages'], 'ask'); + $this->assertStringContainsString('/cms/admin/pages', $prompt); + } +} diff --git a/tests/Unit/Ajax/ServiceFactoryControllerTest.php b/tests/Unit/Ajax/ServiceFactoryControllerTest.php new file mode 100644 index 0000000..8cc1cd3 --- /dev/null +++ b/tests/Unit/Ajax/ServiceFactoryControllerTest.php @@ -0,0 +1,146 @@ +setStorage(new Zend_Auth_Storage_NonPersistent()); + } + + protected function tearDown(): void + { + Zend_Auth::getInstance()->clearIdentity(); + parent::tearDown(); + } + + private function request(array $svc): Zend_Controller_Request_Http + { + $r = new Zend_Controller_Request_Http(); + foreach ($svc as $k => $v) { $r->setParam($k, $v); } + return $r; + } + + private function factory(array $svc): Tiger_Ajax_ServiceFactory + { + return new Tiger_Ajax_ServiceFactory($this->request($svc)); + } + + private function installAcl(string $resource, string $privilege, string $role = 'user'): void + { + $acl = new Zend_Acl(); + $acl->addRole(new Zend_Acl_Role('guest')); + $acl->addRole(new Zend_Acl_Role('user'), 'guest'); + $acl->addResource(new Zend_Acl_Resource($resource)); + $acl->allow($role, $resource, $privilege); + Zend_Registry::set('Zend_Acl', $acl); + } + + private function asUser(string $role = 'user'): void + { + Zend_Auth::getInstance()->getStorage()->write((object) ['role' => $role, 'user_id' => 'u1']); + } + + // ---- controller mode ----------------------------------------------- + + #[Test] + public function controller_mode_hands_back_a_forward_descriptor_for_an_allowed_call(): void + { + $this->installAcl('Ctlfix_ThingController', 'go'); + $this->asUser('user'); + + $f = $this->factory(['svc_module' => 'ctlfix', 'svc_controller' => 'thing', 'svc_action' => 'go', 'extra' => 'v']); + + $fwd = $f->getForward(); + $this->assertIsArray($fwd, 'controller mode signals a _forward, not a service response'); + $this->assertSame('ctlfix', $fwd['module']); + $this->assertSame('thing', $fwd['controller']); + $this->assertSame('go', $fwd['action']); + $this->assertSame('v', $fwd['params']['extra'], 'the whole message rides along to the action'); + } + + #[Test] + public function controller_mode_denies_an_unauthorized_role_before_forwarding(): void + { + $this->installAcl('Ctlfix_ThingController', 'go'); // user may 'go' + $this->asUser('user'); + + $f = $this->factory(['svc_module' => 'ctlfix', 'svc_controller' => 'thing', 'svc_action' => 'nope']); + $this->assertNull($f->getForward(), 'a denied controller call never forwards'); + $this->assertSame('core.api.error.not_allowed', $f->getResponse()->messages[0]->message); + } + + #[Test] + public function controller_mode_fails_generically_when_the_action_method_is_missing(): void + { + // Authorized, class exists, but there is no `missingAction` — resolution fails with the generic error. + $this->installAcl('Ctlfix_ThingController', 'missing'); + $this->asUser('user'); + + $f = $this->factory(['svc_module' => 'ctlfix', 'svc_controller' => 'thing', 'svc_action' => 'missing']); + $this->assertNull($f->getForward()); + $this->assertSame('core.api.error.general', $f->getResponse()->messages[0]->message); + } + + // ---- shape guards + config fallback -------------------------------- + + #[Test] + public function neither_service_nor_controller_is_a_missing_service_error(): void + { + $f = $this->factory(['svc_action' => 'go']); // action only, no service, no controller + $this->assertSame('core.api.error.missing_service', $f->getResponse()->messages[0]->message); + } + + #[Test] + public function the_default_module_config_lets_a_message_omit_the_module(): void + { + // With tiger.api.default_module set, an omitted module resolves to it — so we get past the + // missing_module guard to (no ACL => fail open =>) the missing class → generic error. Without the + // default, the same message would throw missing_module. + $this->setConfig(['tiger' => ['api' => ['default_module' => 'account']]]); + + $f = $this->factory(['svc_service' => 'user', 'svc_action' => 'save']); // no svc_module + $this->assertSame('core.api.error.general', $f->getResponse()->messages[0]->message, 'the default module filled in'); + } + + #[Test] + public function without_a_default_module_an_omitted_module_is_missing_module(): void + { + $f = $this->factory(['svc_service' => 'user', 'svc_action' => 'save']); // no module, no default + $this->assertSame('core.api.error.missing_module', $f->getResponse()->messages[0]->message); + } + } +} + +namespace { + + /** A fixture controller for the factory's class_exists + method_exists(Action) checks. */ + class Ctlfix_ThingController + { + public function goAction() {} + } +} diff --git a/tests/Unit/Application/BootstrapHelpersTest.php b/tests/Unit/Application/BootstrapHelpersTest.php new file mode 100644 index 0000000..48c8366 --- /dev/null +++ b/tests/Unit/Application/BootstrapHelpersTest.php @@ -0,0 +1,127 @@ +server = $_SERVER; + $this->bootstrap = new Tiger_Application_Bootstrap(new Zend_Application('testing')); + } + + protected function tearDown(): void + { + $_SERVER = $this->server; + Zend_Auth::getInstance()->clearIdentity(); + parent::tearDown(); + } + + private function call(string $method, array $args = []) + { + return (new ReflectionMethod(Tiger_Application_Bootstrap::class, $method))->invokeArgs($this->bootstrap, $args); + } + + /** Sign a synthetic identity into the process singleton (in-memory, no session). */ + private function identify(?array $identity): void + { + $auth = Zend_Auth::getInstance(); + $auth->setStorage(new Zend_Auth_Storage_NonPersistent()); + if ($identity !== null) { + $auth->getStorage()->write((object) $identity); + } + } + + #[Test] + public function set_nested_config_folds_a_dot_key_into_the_tree(): void + { + $config = new Zend_Config(['tiger' => ['theme' => 'puma']], true); + + $this->call('_setNestedConfig', [$config, 'tiger.skin', 'jaguar']); + $this->call('_setNestedConfig', [$config, 'tiger.theme', 'aurora']); // overwrite a leaf + $this->call('_setNestedConfig', [$config, 'a.b.c.d', 'deep']); // create the whole branch + + $this->assertSame('jaguar', $config->tiger->skin); + $this->assertSame('aurora', $config->tiger->theme, 'an existing leaf is overwritten (DB tier wins)'); + $this->assertSame('deep', $config->a->b->c->d); + } + + #[Test] + public function bearer_request_detects_a_tiger_token(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer tgr_abc123'; + $this->assertTrue($this->call('_bearerRequest')); + + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer someothertoken'; + $this->assertFalse($this->call('_bearerRequest'), 'a non-tgr bearer is not a Tiger token'); + + unset($_SERVER['HTTP_AUTHORIZATION']); + $this->assertFalse($this->call('_bearerRequest'), 'no Authorization header → not a token request'); + + // The proxied header variant (Apache rewrites Authorization into REDIRECT_*). + $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = 'Bearer tgr_x'; + $this->assertTrue($this->call('_bearerRequest')); + } + + #[Test] + public function current_org_and_user_come_from_the_identity(): void + { + $this->identify(['user_id' => 'u-1', 'org_id' => 'o-9']); + $this->assertSame('o-9', $this->call('_currentOrgId')); + $this->assertSame('u-1', $this->call('_currentUserId')); + } + + #[Test] + public function current_org_and_user_are_null_without_an_identity(): void + { + $this->identify(null); + $this->assertNull($this->call('_currentOrgId')); + $this->assertNull($this->call('_currentUserId')); + } + + #[Test] + public function current_org_is_null_when_the_identity_carries_no_org(): void + { + $this->identify(['user_id' => 'u-1']); // no org_id + $this->assertNull($this->call('_currentOrgId')); + $this->assertSame('u-1', $this->call('_currentUserId')); + } + + #[Test] + public function language_files_return_the_existing_cascade_members(): void + { + // Constants point at the repo (test bootstrap), so the core `en` file resolves; every returned + // path is a real file (missing cascade members are filtered out). + $files = $this->call('_languageFiles', ['en']); + $this->assertIsArray($files); + $this->assertNotEmpty($files); + foreach ($files as $f) { + $this->assertFileExists($f); + } + $this->assertContains(TIGER_CORE_PATH . '/core/languages/en/core.php', $files, 'the core language file leads the cascade'); + } +} diff --git a/tests/Unit/ApplicationTest.php b/tests/Unit/ApplicationTest.php new file mode 100644 index 0000000..bdc3825 --- /dev/null +++ b/tests/Unit/ApplicationTest.php @@ -0,0 +1,141 @@ +server = $_SERVER; // proxy normalization mutates $_SERVER — snapshot it + $this->includePath = get_include_path(); + } + + protected function tearDown(): void + { + $_SERVER = $this->server; + set_include_path($this->includePath); + parent::tearDown(); + } + + private function app(): Tiger_Application + { + return new Tiger_Application(APPLICATION_ROOT); + } + + private function call(Tiger_Application $app, string $method, array $args = []) + { + return (new ReflectionMethod(Tiger_Application::class, $method))->invokeArgs($app, $args); + } + + #[Test] + public function the_constructor_normalizes_the_root_path(): void + { + $app = new Tiger_Application('C:\\sites\\my-app\\'); // backslashes + a trailing slash + $root = (new ReflectionProperty(Tiger_Application::class, 'root'))->getValue($app); + $this->assertSame('C:/sites/my-app', $root); + } + + #[Test] + public function normalize_proxy_applies_the_forwarded_client_and_https(): void + { + $_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.7, 10.0.0.1, 10.0.0.2'; // client is leftmost + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + unset($_SERVER['HTTPS'], $_SERVER['SERVER_PORT']); + + $this->call($this->app(), 'normalizeProxy'); + + $this->assertSame('203.0.113.7', $_SERVER['REMOTE_ADDR'], 'the original client, not an ALB hop'); + $this->assertSame('on', $_SERVER['HTTPS']); + $this->assertSame(443, $_SERVER['SERVER_PORT']); + $this->assertTrue(defined('HTTPS'), 'the HTTPS boolean constant is exposed'); + } + + #[Test] + public function normalize_proxy_leaves_plain_http_alone(): void + { + unset($_SERVER['HTTP_X_FORWARDED_FOR'], $_SERVER['HTTP_X_FORWARDED_PROTO'], $_SERVER['HTTPS']); + $_SERVER['SERVER_PORT'] = 80; + + $this->call($this->app(), 'normalizeProxy'); + + $this->assertArrayNotHasKey('HTTPS', $_SERVER, 'no forwarded-proto → HTTPS is not forced on'); + $this->assertSame(80, $_SERVER['SERVER_PORT']); + } + + /** + * Runs in a SEPARATE PROCESS: `defineConstants()` mints ~8 process-global constants the test + * bootstrap deliberately leaves unset (MODULES_PATH, PROJECT_LIBRARY_PATH, …). Defining them in + * the parent would leak — e.g. `ScanServiceTest` conditionally defines MODULES_PATH itself — so + * this test forks. `setIncludePath()` depends on those constants, so it's asserted here too. + */ + #[Test] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function define_constants_and_include_path_from_the_root(): void + { + $app = $this->app(); + + $this->call($app, 'defineConstants'); + $this->assertTrue(defined('TIGER_VERSION')); + $this->assertSame(Tiger_Version::VERSION, TIGER_VERSION, 'the canonical version IS tiger-core’s'); + $this->assertTrue(defined('MODULES_PATH')); + $this->assertTrue(defined('APPLICATION_ENV')); + + $this->call($app, 'setIncludePath'); + $parts = explode(PATH_SEPARATOR, get_include_path()); + $this->assertSame(PROJECT_LIBRARY_PATH, $parts[0], 'the app library resolves ahead of the framework'); + $this->assertContains(TIGER_CORE_PATH . '/library', $parts); + } + + #[Test] + public function load_custom_hook_is_a_no_op_when_absent(): void + { + // No custom.php at the (repo) root → the hook simply does nothing and never fatals. + $this->assertNull($this->call($this->app(), 'loadCustomHook')); + } + + #[Test] + public function build_config_merges_the_ini_cascade_into_a_read_only_config(): void + { + $config = $this->call($this->app(), 'buildConfig'); + + $this->assertInstanceOf(Zend_Config::class, $config); + $this->assertTrue($config->readOnly(), 'the published config is frozen'); + // core.ini is the base of the cascade — its keys are present. + $this->assertSame('puma', (string) $config->tiger->theme); + $this->assertNotNull($config->resources, 'the resources tree loaded from core.ini'); + } + + #[Test] + public function update_in_progress_is_false_without_the_maintenance_flag(): void + { + // No var/update/.maintenance flag on disk → normal dispatch (returns false, serves nothing). + $this->assertFalse($this->call($this->app(), '_updateInProgress')); + } +} diff --git a/tests/Unit/Backup/ArchiveTest.php b/tests/Unit/Backup/ArchiveTest.php new file mode 100644 index 0000000..f64ec1b --- /dev/null +++ b/tests/Unit/Backup/ArchiveTest.php @@ -0,0 +1,180 @@ +markTestSkipped('No zip backend (ext-zip or ext-phar) available.'); + } + $this->sandbox = sys_get_temp_dir() . '/tiger-arch-' . bin2hex(random_bytes(6)); + mkdir($this->sandbox, 0777, true); + } + + protected function tearDown(): void + { + $this->rmrf($this->sandbox); + 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); + } + + // ---- available() --------------------------------------------------------------------------- + + #[Test] + public function available_is_true_when_a_backend_exists(): void + { + // setUp already skipped if neither exists, so here it must be true — and it agrees with the + // raw class_exists() the shim uses internally. + $this->assertTrue(Tiger_Backup_Archive::available()); + $this->assertSame( + class_exists('ZipArchive') || class_exists('PharData'), + Tiger_Backup_Archive::available() + ); + } + + // ---- build() + read() ---------------------------------------------------------------------- + + #[Test] + public function build_writes_both_a_file_entry_and_an_inline_data_entry(): void + { + // A real on-disk source for the 'file' entry kind… + $src = $this->sandbox . '/source.txt'; + file_put_contents($src, 'file entry contents'); + + $zip = $this->sandbox . '/out.zip'; + Tiger_Backup_Archive::build($zip, [ + ['name' => 'from-file.txt', 'file' => $src], + ['name' => 'manifest.json', 'data' => '{"ok":true}'], + ]); + + $this->assertFileExists($zip); + // read() pulls each entry back out by its archive name. + $this->assertSame('file entry contents', Tiger_Backup_Archive::read($zip, 'from-file.txt')); + $this->assertSame('{"ok":true}', Tiger_Backup_Archive::read($zip, 'manifest.json')); + } + + #[Test] + public function read_returns_false_for_an_absent_entry(): void + { + $zip = $this->sandbox . '/out.zip'; + Tiger_Backup_Archive::build($zip, [['name' => 'only.txt', 'data' => 'x']]); + $this->assertFalse(Tiger_Backup_Archive::read($zip, 'does-not-exist.txt')); + } + + #[Test] + public function build_overwrites_a_pre_existing_archive(): void + { + $zip = $this->sandbox . '/out.zip'; + // A stale file sits at the target path first… + file_put_contents($zip, 'not a zip — should be replaced'); + + Tiger_Backup_Archive::build($zip, [['name' => 'fresh.txt', 'data' => 'fresh bytes']]); + + // …and the new archive fully replaced it (the stale entry name is gone; the new one reads). + $this->assertSame('fresh bytes', Tiger_Backup_Archive::read($zip, 'fresh.txt')); + } + + // ---- extract() ----------------------------------------------------------------------------- + + #[Test] + public function extract_unpacks_the_whole_archive_to_a_directory(): void + { + $zip = $this->sandbox . '/out.zip'; + Tiger_Backup_Archive::build($zip, [ + ['name' => 'a.txt', 'data' => 'alpha'], + ['name' => 'nested/b.txt', 'data' => 'bravo'], + ]); + + $dest = $this->sandbox . '/unpacked'; + Tiger_Backup_Archive::extract($zip, $dest); + + $this->assertFileExists($dest . '/a.txt'); + $this->assertSame('alpha', file_get_contents($dest . '/a.txt')); + $this->assertFileExists($dest . '/nested/b.txt'); + $this->assertSame('bravo', file_get_contents($dest . '/nested/b.txt')); + } + + #[Test] + public function extract_creates_a_missing_destination_directory(): void + { + $zip = $this->sandbox . '/out.zip'; + Tiger_Backup_Archive::build($zip, [['name' => 'x.txt', 'data' => 'y']]); + + // A destination that does not yet exist is created by extract(). + $dest = $this->sandbox . '/brand/new/deep'; + $this->assertDirectoryDoesNotExist($dest); + Tiger_Backup_Archive::extract($zip, $dest); + $this->assertFileExists($dest . '/x.txt'); + } + + // ---- full round trip ----------------------------------------------------------------------- + + #[Test] + public function build_then_extract_preserves_every_entry_byte_for_byte(): void + { + $binary = random_bytes(1024); + $src = $this->sandbox . '/db.sql'; + file_put_contents($src, "-- dump\nINSERT INTO t VALUES (1);\n"); + + $zip = $this->sandbox . '/backup.zip'; + Tiger_Backup_Archive::build($zip, [ + ['name' => 'database/db.sql', 'file' => $src], + ['name' => 'blob.bin', 'data' => $binary], + ['name' => 'manifest.json', 'data' => '{"tables":1}'], + ]); + + $dest = $this->sandbox . '/restore'; + Tiger_Backup_Archive::extract($zip, $dest); + + $this->assertSame(file_get_contents($src), file_get_contents($dest . '/database/db.sql')); + $this->assertSame($binary, file_get_contents($dest . '/blob.bin')); + $this->assertSame('{"tables":1}', file_get_contents($dest . '/manifest.json')); + } +} diff --git a/tests/Unit/Controller/RouteOverridePluginTest.php b/tests/Unit/Controller/RouteOverridePluginTest.php new file mode 100644 index 0000000..cd7df29 --- /dev/null +++ b/tests/Unit/Controller/RouteOverridePluginTest.php @@ -0,0 +1,140 @@ +resetInstance(); + } + + protected function tearDown(): void + { + Tiger_Routing_Overrides::clear(); + Zend_Controller_Front::getInstance()->resetInstance(); + parent::tearDown(); + } + + private function http(string $pathInfo): Zend_Controller_Request_Http + { + $r = new Zend_Controller_Request_Http(); + $r->setPathInfo($pathInfo); + return $r; + } + + private function plugin(): Tiger_Controller_Plugin_RouteOverride + { + return new Tiger_Controller_Plugin_RouteOverride(); + } + + #[Test] + public function a_non_http_request_is_left_untouched(): void + { + Tiger_Routing_Overrides::register('docs', ['pattern' => 'docs', 'target' => 'docs/index/docs']); + $req = new Zend_Controller_Request_Simple(); + $req->setControllerName('foo'); + + $this->plugin()->routeShutdown($req); + $this->assertSame('foo', $req->getControllerName(), 'a Simple request has no pathInfo to rewrite'); + } + + #[Test] + public function a_nested_path_under_the_prefix_is_rewritten_with_the_remainder_as_slug(): void + { + Tiger_Routing_Overrides::register('docs', ['pattern' => 'docs', 'target' => 'docs/index/docs']); + $req = $this->http('/docs/getting-started/install'); + + $this->plugin()->routeShutdown($req); + + $this->assertSame('docs', $req->getModuleName()); + $this->assertSame('index', $req->getControllerName()); + $this->assertSame('docs', $req->getActionName()); + $this->assertSame('getting-started/install', $req->getParam('slug')); + } + + #[Test] + public function an_exact_prefix_match_rewrites_with_no_slug(): void + { + Tiger_Routing_Overrides::register('docs', ['pattern' => 'docs', 'target' => 'docs/index/docs']); + $req = $this->http('/docs'); + + $this->plugin()->routeShutdown($req); + + $this->assertSame('docs', $req->getModuleName()); + $this->assertNull($req->getParam('slug'), 'a bare prefix carries no slug'); + } + + #[Test] + public function a_path_matching_no_override_is_left_untouched(): void + { + Tiger_Routing_Overrides::register('docs', ['pattern' => 'docs', 'target' => 'docs/index/docs']); + $req = $this->http('/pricing'); + + $this->plugin()->routeShutdown($req); + $this->assertNull($req->getModuleName(), 'no matching override => request not rewritten'); + } + + #[Test] + public function an_empty_path_is_left_untouched(): void + { + Tiger_Routing_Overrides::register('docs', ['pattern' => 'docs', 'target' => 'docs/index/docs']); + $req = $this->http('/'); + + $this->plugin()->routeShutdown($req); + $this->assertNull($req->getModuleName()); + } + + #[Test] + public function overlapping_prefixes_resolve_to_the_highest_priority_first(): void + { + // Both prefixes match "/shop/cart/42"; the higher-priority, more-specific one wins. + Tiger_Routing_Overrides::register('broad', ['pattern' => 'shop', 'target' => 'store/home/index', 'priority' => 100]); + Tiger_Routing_Overrides::register('narrow', ['pattern' => 'shop/cart', 'target' => 'store/cart/view', 'priority' => 200]); + + $req = $this->http('/shop/cart/42'); + $this->plugin()->routeShutdown($req); + + $this->assertSame('cart', $req->getControllerName(), 'the higher-priority override claimed it'); + $this->assertSame('view', $req->getActionName()); + $this->assertSame('42', $req->getParam('slug')); + } + + #[Test] + public function a_prefix_that_only_shares_a_leading_substring_does_not_match(): void + { + // "/documentation" must NOT match the "docs" prefix — only "docs" or "docs/…". + Tiger_Routing_Overrides::register('docs', ['pattern' => 'docs', 'target' => 'docs/index/docs']); + $req = $this->http('/documentation'); + + $this->plugin()->routeShutdown($req); + $this->assertNull($req->getModuleName()); + } +} diff --git a/tests/Unit/Controller/ThemeContentPluginTest.php b/tests/Unit/Controller/ThemeContentPluginTest.php new file mode 100644 index 0000000..516037c --- /dev/null +++ b/tests/Unit/Controller/ThemeContentPluginTest.php @@ -0,0 +1,136 @@ +.phtml` (this) → 404. So a vendor theme can ship static pages + * as body partials, rendered through the one theme layout, with no per-page DB rows. + * + * It only fires when nothing real claims the URL (isDispatchable — always false here, no controller + * dirs) and a theme dir is registered. The slug is a strict, dot-free token (no traversal out of + * `content/`), and the theme's stock ".html" links resolve (the suffix is stripped). No DB: the test + * points Tiger_ThemeDir at a temp theme with a real content partial and drives the request. + */ +#[CoversClass(Tiger_Controller_Plugin_ThemeContent::class)] +final class ThemeContentPluginTest extends UnitTestCase +{ + private static string $themeDir; + + public static function setUpBeforeClass(): void + { + self::$themeDir = sys_get_temp_dir() . '/tiger-theme-test-' . getmypid(); + @mkdir(self::$themeDir . '/content', 0777, true); + file_put_contents(self::$themeDir . '/content/about.phtml', '

About

'); + } + + protected function setUp(): void + { + parent::setUp(); + Zend_Controller_Front::getInstance()->resetInstance(); + } + + protected function tearDown(): void + { + Zend_Controller_Front::getInstance()->resetInstance(); + parent::tearDown(); + } + + private function http(string $pathInfo): Zend_Controller_Request_Http + { + $r = new Zend_Controller_Request_Http(); + $r->setPathInfo($pathInfo); + return $r; + } + + private function plugin(): Tiger_Controller_Plugin_ThemeContent + { + return new Tiger_Controller_Plugin_ThemeContent(); + } + + #[Test] + public function a_non_http_request_is_ignored(): void + { + Zend_Registry::set('Tiger_ThemeDir', self::$themeDir); + $req = new Zend_Controller_Request_Simple(); + $req->setControllerName('foo'); + + $this->plugin()->routeShutdown($req); + $this->assertSame('foo', $req->getControllerName()); + } + + #[Test] + public function with_no_theme_dir_registered_the_request_is_left_untouched(): void + { + $req = $this->http('/about'); // no Tiger_ThemeDir in the (per-test reset) registry + $this->plugin()->routeShutdown($req); + $this->assertNull($req->getModuleName()); + } + + #[Test] + public function an_existing_content_partial_routes_to_the_theme_content_action(): void + { + Zend_Registry::set('Tiger_ThemeDir', self::$themeDir); + $req = $this->http('/about'); + + $this->plugin()->routeShutdown($req); + + $this->assertSame('page', $req->getControllerName()); + $this->assertSame('theme-content', $req->getActionName()); + $this->assertSame('about', $req->getParam('theme_content_slug')); + } + + #[Test] + public function a_stock_html_suffix_resolves_the_same_partial(): void + { + Zend_Registry::set('Tiger_ThemeDir', self::$themeDir); + $req = $this->http('/about.html'); // the vendor theme's original ".html" link + + $this->plugin()->routeShutdown($req); + + $this->assertSame('theme-content', $req->getActionName()); + $this->assertSame('about', $req->getParam('theme_content_slug'), 'the .html suffix is stripped'); + } + + #[Test] + public function a_slug_with_no_matching_partial_is_left_untouched(): void + { + Zend_Registry::set('Tiger_ThemeDir', self::$themeDir); + $req = $this->http('/nonexistent'); + + $this->plugin()->routeShutdown($req); + $this->assertNull($req->getModuleName()); + } + + #[Test] + public function a_dotted_traversal_slug_is_refused(): void + { + Zend_Registry::set('Tiger_ThemeDir', self::$themeDir); + // A '.' in the slug fails the strict dot-free token guard, so it can never escape content/. + $req = $this->http('/../about'); + + $this->plugin()->routeShutdown($req); + $this->assertNull($req->getModuleName()); + } + + #[Test] + public function the_root_path_is_left_untouched(): void + { + Zend_Registry::set('Tiger_ThemeDir', self::$themeDir); + $req = $this->http('/'); + + $this->plugin()->routeShutdown($req); + $this->assertNull($req->getModuleName()); + } +} diff --git a/tests/Unit/Generator/ModuleTest.php b/tests/Unit/Generator/ModuleTest.php new file mode 100644 index 0000000..1e64005 --- /dev/null +++ b/tests/Unit/Generator/ModuleTest.php @@ -0,0 +1,131 @@ +tmp = sys_get_temp_dir() . '/tiger_gen_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->tmp, 0775, true); + } + + protected function tearDown(): void + { + $this->rrmdir($this->tmp); + parent::tearDown(); + } + + #[Test] + public function it_generates_the_full_module_tree(): void + { + $created = Tiger_Generator_Module::generate('shop', $this->tmp); + + $dir = $this->tmp . '/shop'; + $this->assertDirectoryExists($dir); + + // Every declared template landed on disk. + foreach ([ + 'Bootstrap.php', + 'controllers/IndexController.php', + 'services/Example.php', + 'views/scripts/index/index.phtml', + 'layouts/.gitkeep', + 'assets/.gitkeep', + 'configs/module.ini', + 'configs/acl.ini', + 'configs/routes.ini', + 'configs/dependency.ini', + 'models/.gitkeep', + 'migrations/.gitkeep', + ] as $rel) { + $this->assertFileExists($dir . '/' . $rel, "$rel should be created"); + $this->assertContains($dir . '/' . $rel, $created, "$rel should be in the returned list"); + } + $this->assertCount(12, $created); + } + + #[Test] + public function placeholders_are_substituted_lowercase_and_ucfirst(): void + { + Tiger_Generator_Module::generate('shop', $this->tmp); + $dir = $this->tmp . '/shop'; + + $bootstrap = file_get_contents($dir . '/Bootstrap.php'); + $this->assertStringContainsString('class Shop_Bootstrap extends', $bootstrap, '{{Name}} → Shop'); + $this->assertStringNotContainsString('{{Name}}', $bootstrap); + $this->assertStringNotContainsString('{{name}}', $bootstrap); + + $service = file_get_contents($dir . '/services/Example.php'); + $this->assertStringContainsString('class Shop_Service_Example extends Tiger_Service_Service', $service); + $this->assertStringContainsString("'module' => 'shop'", $service, '{{name}} → shop in emitted code'); + + $controller = file_get_contents($dir . '/controllers/IndexController.php'); + $this->assertStringContainsString('class Shop_IndexController extends Tiger_Controller_Action', $controller); + + $acl = file_get_contents($dir . '/configs/acl.ini'); + $this->assertStringContainsString('"Shop_IndexController"', $acl); + $this->assertStringContainsString('acl.resources.shop_index.resource', $acl); + } + + #[Test] + public function a_name_is_trimmed_and_lowercased_before_validation(): void + { + // Leading/trailing whitespace and mixed case are normalized, not rejected. + $created = Tiger_Generator_Module::generate(' Blog ', $this->tmp); + $this->assertDirectoryExists($this->tmp . '/blog'); + $this->assertNotEmpty($created); + } + + #[Test] + public function an_invalid_name_is_rejected(): void + { + foreach (['1shop', 'my-shop', 'my_shop', 'Shop!', ''] as $bad) { + try { + Tiger_Generator_Module::generate($bad, $this->tmp); + $this->fail("name '$bad' should have been rejected"); + } catch (RuntimeException $e) { + $this->assertStringContainsString('lowercase word', $e->getMessage()); + } + } + } + + #[Test] + public function an_existing_module_is_refused(): void + { + Tiger_Generator_Module::generate('shop', $this->tmp); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Module 'shop' already exists"); + Tiger_Generator_Module::generate('shop', $this->tmp); + } + + 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/Unit/Google/AnalyticsTest.php b/tests/Unit/Google/AnalyticsTest.php new file mode 100644 index 0000000..42097c3 --- /dev/null +++ b/tests/Unit/Google/AnalyticsTest.php @@ -0,0 +1,298 @@ +resetAccess(); + } + + protected function tearDown(): void + { + $this->resetAccess(); + parent::tearDown(); + } + + /** Clear the memoized per-request access token so tests don't bleed into each other. */ + private function resetAccess(): void + { + (new ReflectionProperty(Tiger_Google_Analytics::class, '_access'))->setValue(null, null); + } + + /** Seed a crypto key, then return an encrypted blob for a value (key must be registered first). */ + private function seedCryptoAndEncrypt(string $plain): array + { + $key = base64_encode(str_repeat("\x11", 32)); + $this->setConfig(['tiger' => ['crypto' => ['key' => $key]]]); + return ['key' => $key, 'enc' => Tiger_Crypto::encrypt($plain)]; + } + + // -- config normalization ---------------------------------------------------------------------- + + #[Test] + public function mode_defaults_to_broker_and_honors_byo(): void + { + $this->setConfig(['tiger' => []]); + $this->assertSame('broker', Tiger_Google_Analytics::mode()); + + $this->setConfig(['tiger' => ['analytics' => ['oauth' => ['mode' => 'byo']]]]); + $this->assertSame('byo', Tiger_Google_Analytics::mode()); + + $this->setConfig(['tiger' => ['analytics' => ['oauth' => ['mode' => 'nonsense']]]]); + $this->assertSame('broker', Tiger_Google_Analytics::mode(), 'an unknown mode falls back to broker'); + } + + #[Test] + public function broker_base_defaults_and_strips_a_trailing_slash(): void + { + $this->setConfig(['tiger' => []]); + $this->assertSame('https://connect.webtigers.com', Tiger_Google_Analytics::brokerBase()); + + $this->setConfig(['tiger' => ['analytics' => ['connect' => ['base_url' => 'https://broker.example/']]]]); + $this->assertSame('https://broker.example', Tiger_Google_Analytics::brokerBase()); + } + + #[Test] + public function property_id_keeps_only_digits(): void + { + $this->setConfig(['tiger' => ['analytics' => ['property_id' => 'GA-123 456_789']]]); + $this->assertSame('123456789', Tiger_Google_Analytics::propertyId()); + } + + #[Test] + public function client_id_is_trimmed(): void + { + $this->setConfig(['tiger' => ['analytics' => ['oauth' => ['client_id' => ' cid.apps ']]]]); + $this->assertSame('cid.apps', Tiger_Google_Analytics::clientId()); + } + + // -- connection state -------------------------------------------------------------------------- + + #[Test] + public function it_is_not_connected_without_a_property_id(): void + { + $this->setConfig(['tiger' => ['analytics' => []]]); + $this->assertFalse(Tiger_Google_Analytics::isConnected()); + $this->assertFalse(Tiger_Google_Analytics::isConfigurable()); + } + + #[Test] + public function broker_mode_is_connected_with_a_property_and_a_refresh_token(): void + { + $c = $this->seedCryptoAndEncrypt('refresh-abc'); + $this->setConfig(['tiger' => [ + 'crypto' => ['key' => $c['key']], + 'analytics' => [ + 'property_id' => '123456789', + 'oauth' => ['refresh_token_enc' => $c['enc']], // mode defaults to broker + ], + ]]); + + $this->assertTrue(Tiger_Google_Analytics::isConnected()); + $this->assertTrue(Tiger_Google_Analytics::isConfigurable(), 'broker only needs the property id to be configurable'); + } + + #[Test] + public function broker_mode_needs_a_refresh_token_to_be_connected(): void + { + $this->setConfig(['tiger' => ['analytics' => ['property_id' => '123456789']]]); + $this->assertFalse(Tiger_Google_Analytics::isConnected(), 'property but no refresh token'); + $this->assertTrue(Tiger_Google_Analytics::isConfigurable(), 'still configurable in broker mode'); + } + + #[Test] + public function byo_mode_requires_property_refresh_and_client_creds(): void + { + // one crypto key encrypts both secrets + $key = base64_encode(str_repeat("\x11", 32)); + $this->setConfig(['tiger' => ['crypto' => ['key' => $key]]]); + $refreshEnc = Tiger_Crypto::encrypt('refresh-xyz'); + $secretEnc = Tiger_Crypto::encrypt('client-secret'); + $this->setConfig(['tiger' => [ + 'crypto' => ['key' => $key], + 'analytics' => [ + 'property_id' => '55', + 'oauth' => [ + 'mode' => 'byo', + 'client_id' => 'cid', + 'client_secret_enc' => $secretEnc, + 'refresh_token_enc' => $refreshEnc, + ], + ], + ]]); + + $this->assertTrue(Tiger_Google_Analytics::isConnected()); + $this->assertTrue(Tiger_Google_Analytics::isConfigurable()); + } + + #[Test] + public function byo_mode_is_not_configurable_without_client_creds(): void + { + $this->setConfig(['tiger' => ['analytics' => [ + 'property_id' => '55', + 'oauth' => ['mode' => 'byo'], // no client id/secret + ]]]); + $this->assertFalse(Tiger_Google_Analytics::isConfigurable()); + $this->assertFalse(Tiger_Google_Analytics::isConnected()); + } + + // -- PKCE + OAuth URL construction ------------------------------------------------------------- + + #[Test] + public function pkce_challenge_is_the_unpadded_base64url_sha256_of_the_verifier(): void + { + $this->setConfig(['tiger' => []]); + $verifier = 'a-random-verifier-string'; + $expected = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + $challenge = Tiger_Google_Analytics::pkceChallenge($verifier); + $this->assertSame($expected, $challenge); + $this->assertStringNotContainsString('=', $challenge, 'unpadded'); + $this->assertDoesNotMatchRegularExpression('#[+/]#', $challenge, 'url-safe alphabet'); + } + + #[Test] + public function broker_auth_url_carries_the_callback_and_challenge(): void + { + $this->setConfig(['tiger' => ['analytics' => ['connect' => ['base_url' => 'https://broker.example']]]]); + $url = Tiger_Google_Analytics::brokerAuthUrl('https://app/callback', 'CHAL'); + + $this->assertStringStartsWith('https://broker.example/google/start?', $url); + $this->assertStringContainsString('callback=' . rawurlencode('https://app/callback'), $url); + $this->assertStringContainsString('challenge=CHAL', $url); + } + + #[Test] + public function byo_auth_url_requests_offline_access_and_forces_consent(): void + { + $this->setConfig(['tiger' => ['analytics' => ['oauth' => ['client_id' => 'cid.apps']]]]); + $url = Tiger_Google_Analytics::authUrl('https://app/oauth/cb', 'STATE-1'); + + $this->assertStringStartsWith('https://accounts.google.com/o/oauth2/v2/auth?', $url); + $this->assertStringContainsString('client_id=cid.apps', $url); + $this->assertStringContainsString('redirect_uri=' . rawurlencode('https://app/oauth/cb'), $url); + $this->assertStringContainsString('access_type=offline', $url); + $this->assertStringContainsString('prompt=consent', $url); + $this->assertStringContainsString('scope=' . rawurlencode('https://www.googleapis.com/auth/analytics.readonly'), $url); + $this->assertStringContainsString('state=STATE-1', $url); + } + + // -- guards that return before any network ----------------------------------------------------- + + #[Test] + public function access_token_is_empty_without_a_stored_refresh_token(): void + { + $this->setConfig(['tiger' => ['analytics' => []]]); + $this->assertSame('', Tiger_Google_Analytics::accessToken(), 'no refresh token => no network, empty token'); + } + + #[Test] + public function access_token_returns_the_memoized_value_without_recomputing(): void + { + // once minted for the request, the access token is memoized and returned verbatim (no refresh, + // no network) — even with no config present. + (new ReflectionProperty(Tiger_Google_Analytics::class, '_access'))->setValue(null, 'cached-token'); + $this->setConfig(['tiger' => []]); + $this->assertSame('cached-token', Tiger_Google_Analytics::accessToken()); + } + + #[Test] + public function summary_is_null_when_not_connected(): void + { + $this->setConfig(['tiger' => ['analytics' => []]]); + $this->assertNull(Tiger_Google_Analytics::summary()); + } + + #[Test] + public function test_connection_reports_not_connected_before_calling_out(): void + { + $this->setConfig(['tiger' => ['analytics' => []]]); + $r = Tiger_Google_Analytics::testConnection(); + $this->assertFalse($r['ok']); + $this->assertSame('not_connected', $r['code']); + } + + // -- response shapers (canned GA4 JSON) -------------------------------------------------------- + + #[Test] + public function row_metrics_reads_the_first_row_metric_values_as_floats(): void + { + $canned = ['rows' => [['metricValues' => [['value' => '1234'], ['value' => '56'], ['value' => '78.0']]]]]; + $out = (new ReflectionMethod(Tiger_Google_Analytics::class, '_rowMetrics'))->invoke(null, $canned); + $this->assertSame([1234.0, 56.0, 78.0], $out); + } + + #[Test] + public function row_metrics_is_empty_for_a_report_with_no_rows(): void + { + $out = (new ReflectionMethod(Tiger_Google_Analytics::class, '_rowMetrics'))->invoke(null, ['rows' => []]); + $this->assertSame([], $out); + } + + #[Test] + public function series_reformats_the_compact_date_and_casts_metrics(): void + { + $canned = ['rows' => [ + ['dimensionValues' => [['value' => '20260724']], 'metricValues' => [['value' => '10'], ['value' => '25']]], + ['dimensionValues' => [['value' => 'partial']], 'metricValues' => [['value' => '3'], ['value' => '4']]], + ]]; + $out = (new ReflectionMethod(Tiger_Google_Analytics::class, '_series'))->invoke(null, $canned); + + $this->assertSame('2026-07-24', $out[0]['date'], 'YYYYMMDD => YYYY-MM-DD'); + $this->assertSame(10, $out[0]['users']); + $this->assertSame(25, $out[0]['views']); + $this->assertSame('partial', $out[1]['date'], 'a non-8-char value passes through unchanged'); + } + + #[Test] + public function series_is_empty_for_null_or_rowless_input(): void + { + $m = new ReflectionMethod(Tiger_Google_Analytics::class, '_series'); + $this->assertSame([], $m->invoke(null, null)); + $this->assertSame([], $m->invoke(null, ['rows' => []])); + } + + #[Test] + public function dim_rows_maps_label_and_integer_value(): void + { + $canned = ['rows' => [ + ['dimensionValues' => [['value' => '/home']], 'metricValues' => [['value' => '900']]], + ['dimensionValues' => [['value' => '/pricing']], 'metricValues' => [['value' => '120']]], + ]]; + $out = (new ReflectionMethod(Tiger_Google_Analytics::class, '_dimRows'))->invoke(null, $canned); + + $this->assertSame([['label' => '/home', 'value' => 900], ['label' => '/pricing', 'value' => 120]], $out); + } + + #[Test] + public function dim_rows_is_empty_for_null_input(): void + { + $this->assertSame([], (new ReflectionMethod(Tiger_Google_Analytics::class, '_dimRows'))->invoke(null, null)); + } +} diff --git a/tests/Unit/I18n/CountryRemainderTest.php b/tests/Unit/I18n/CountryRemainderTest.php new file mode 100644 index 0000000..ee6fab2 --- /dev/null +++ b/tests/Unit/I18n/CountryRemainderTest.php @@ -0,0 +1,91 @@ +resetData(); + } + + protected function tearDown(): void + { + $this->resetData(); + parent::tearDown(); + } + + private function resetData(): void + { + (new ReflectionProperty(Tiger_I18n_Country::class, '_data'))->setValue(null, null); + } + + #[Test] + public function name_falls_through_to_cldr_when_the_translator_lacks_the_key(): void + { + // A translator is present but only overrides a DIFFERENT country — the requested one must still + // resolve from CLDR (the isTranslated()==false branch of name()). + Zend_Registry::set('Zend_Translate', new Zend_Translate([ + 'adapter' => 'array', 'content' => ['country.FR' => 'La France'], 'locale' => 'en', + ])); + + $this->assertSame('United States', Tiger_I18n_Country::name('US', 'en'), 'un-overridden => CLDR'); + $this->assertSame('La France', Tiger_I18n_Country::name('FR', 'en'), 'overridden => the override'); + } + + #[Test] + public function all_applies_a_translation_override_within_the_localized_list(): void + { + Zend_Registry::set('Zend_Translate', new Zend_Translate([ + 'adapter' => 'array', 'content' => ['country.US' => 'ZZ Last Place'], 'locale' => 'en', + ])); + + $all = Tiger_I18n_Country::all('en'); + $this->assertSame('ZZ Last Place', $all['US'], 'the override flows through all()'); + // an un-overridden country in the same pass still comes from CLDR. + $this->assertSame('Canada', $all['CA']); + } + + #[Test] + public function grouped_uses_the_override_and_still_partitions_the_full_set(): void + { + Zend_Registry::set('Zend_Translate', new Zend_Translate([ + 'adapter' => 'array', 'content' => ['country.US' => 'Estados Unidos'], 'locale' => 'en', + ])); + + $grouped = Tiger_I18n_Country::grouped('en'); + $this->assertSame('Estados Unidos', $grouped['priority']['US']); + $this->assertSame( + count(Tiger_I18n_Country::codes()), + count($grouped['priority']) + count($grouped['rest']) + ); + } + + #[Test] + public function options_uses_the_default_placeholder_when_none_is_given(): void + { + $opts = Tiger_I18n_Country::options('en'); + $this->assertSame('— Select —', $opts[''], 'the built-in default placeholder'); + } +} diff --git a/tests/Unit/I18n/TimezoneTest.php b/tests/Unit/I18n/TimezoneTest.php new file mode 100644 index 0000000..508d4d9 --- /dev/null +++ b/tests/Unit/I18n/TimezoneTest.php @@ -0,0 +1,79 @@ +assertMatchesRegularExpression( + '#^America/New_York \(E[SD]T, UTC-0[45]:00\)$#', + Tiger_I18n_Timezone::label('America/New_York') + ); + } + + #[Test] + public function label_drops_a_numeric_abbreviation_to_avoid_a_doubled_offset(): void + { + // Kathmandu (+05:45) has no named abbreviation — PHP formats 'T' as "+0545", which is dropped so + // the label carries the offset exactly once. + $this->assertSame('Asia/Kathmandu (UTC+05:45)', Tiger_I18n_Timezone::label('Asia/Kathmandu')); + } + + #[Test] + public function label_for_utc_shows_a_zero_offset(): void + { + $this->assertSame('UTC (UTC, UTC+00:00)', Tiger_I18n_Timezone::label('UTC')); + } + + #[Test] + public function an_invalid_id_returns_the_bare_id(): void + { + $this->assertSame('Not/AZone', Tiger_I18n_Timezone::label('Not/AZone')); + } + + #[Test] + public function options_is_a_non_empty_id_to_label_map_of_valid_zones(): void + { + $opts = Tiger_I18n_Timezone::options(); + $this->assertGreaterThan(400, count($opts)); + $this->assertArrayHasKey('UTC', $opts); + $this->assertArrayHasKey('America/New_York', $opts); + // every key is a real IANA id, and its value is the composed label. + $this->assertStringContainsString('America/New_York (', $opts['America/New_York']); + } + + #[Test] + public function options_are_ordered_by_current_utc_offset(): void + { + $keys = array_keys(Tiger_I18n_Timezone::options()); + $first = new DateTimeZone($keys[0]); + $last = new DateTimeZone($keys[count($keys) - 1]); + $now = new \DateTime('now'); + $this->assertLessThanOrEqual( + $last->getOffset($now), + $first->getOffset($now), + 'the list runs from the most-negative offset to the most-positive' + ); + } +} diff --git a/tests/Unit/License/CheckerStatusTest.php b/tests/Unit/License/CheckerStatusTest.php new file mode 100644 index 0000000..c3f556f --- /dev/null +++ b/tests/Unit/License/CheckerStatusTest.php @@ -0,0 +1,69 @@ +store = new W5LicenseStore(); + Tiger_License_Checker::setStore($this->store); + } + + protected function tearDown(): void + { + Tiger_License_Checker::_reset(); + parent::tearDown(); + } + + #[Test] + public function status_of_a_held_but_never_verified_license_is_unknown(): void + { + // A key is on file but no verdict has ever been cached — the badge reads UNKNOWN, and it still + // permits updates (nag-never-disable: unknown is assume-current, not lapsed). + $this->store->put('shop', ['key' => 'K-1', 'authority' => 'https://a', 'public_key' => '']); + + $v = Tiger_License_Checker::status('shop'); + $this->assertSame(Tiger_License_Checker::UNKNOWN, $v['state']); + $this->assertTrue($v['can_update']); + } + + #[Test] + public function verify_of_a_record_missing_its_key_is_unlicensed(): void + { + // A malformed record (authority but no key) is treated as nothing to gate — UNLICENSED, ungated. + $this->store->put('shop', ['authority' => 'https://a']); + $v = Tiger_License_Checker::verify('shop', [], 1000); + $this->assertSame(Tiger_License_Checker::UNLICENSED, $v['state']); + $this->assertTrue($v['can_update']); + } +} + +/** In-memory license store (no DB). */ +final class W5LicenseStore implements Tiger_License_Store +{ + /** @var array */ + public array $data = []; + + public function get(string $slug): ?array { return $this->data[$slug] ?? null; } + public function put(string $slug, array $record): void { $this->data[$slug] = $record; } + public function forget(string $slug): void { unset($this->data[$slug]); } +} diff --git a/tests/Unit/Location/AdapterAbstractTest.php b/tests/Unit/Location/AdapterAbstractTest.php new file mode 100644 index 0000000..e09616b --- /dev/null +++ b/tests/Unit/Location/AdapterAbstractTest.php @@ -0,0 +1,71 @@ +assertSame([], $a->capabilities()); + $this->assertFalse($a->supports(Tiger_Location_Adapter_Interface::CAP_SUGGEST)); + $this->assertSame([], $a->fields()); + } + + #[Test] + public function label_defaults_to_the_class_name_suffix(): void + { + $this->assertSame('BareAdapter', (new Tiger_Test_BareAdapter())->label()); + } + + #[Test] + public function cfg_reads_the_config_slice_with_a_default(): void + { + $a = new Tiger_Test_BareAdapter(['endpoint' => 'https://x', 'zero' => 0]); + $m = new ReflectionMethod($a, '_cfg'); + $this->assertSame('https://x', $m->invoke($a, 'endpoint')); + $this->assertSame(0, $m->invoke($a, 'zero', 'fallback'), 'a present-but-falsy value is returned, not the default'); + $this->assertSame('fallback', $m->invoke($a, 'missing', 'fallback')); + $this->assertNull($m->invoke($a, 'missing')); + } + + #[Test] + public function every_operation_is_unsupported_by_default(): void + { + $a = new Tiger_Test_BareAdapter(); + + foreach (['suggest' => ['q'], 'geocode' => ['q'], 'reverse' => [1.0, 2.0], 'ip' => ['8.8.8.8']] as $op => $args) { + try { + $a->$op(...$args); + $this->fail("$op should throw when unsupported"); + } catch (Tiger_Location_Exception $e) { + $this->assertStringContainsString('does not support', $e->getMessage()); + $this->assertStringContainsString($op, $e->getMessage()); + } + } + } +} diff --git a/tests/Unit/Location/AwsTest.php b/tests/Unit/Location/AwsTest.php new file mode 100644 index 0000000..dbd46b8 --- /dev/null +++ b/tests/Unit/Location/AwsTest.php @@ -0,0 +1,241 @@ + 'us-east-1', 'place_index' => 'MyIndex', 'key' => 'AKIA_TEST', 'secret' => 'SECRET_TEST']; + + #[Test] + public function capabilities_and_label(): void + { + $a = new Tiger_Location_Adapter_Aws(); + $this->assertSame(['suggest', 'geocode', 'reverse'], $a->capabilities()); + $this->assertSame('AWS Location Service', $a->label()); + $this->assertNotEmpty($a->fields()); + } + + // -- unprovisioned guard (real _call, but it bails before any network) -------------------------- + + #[Test] + public function an_unprovisioned_adapter_degrades_to_empty_without_calling_out(): void + { + $a = new Tiger_Location_Adapter_Aws(); // no region/index/key/secret + $this->assertSame([], $a->geocode('anywhere')); + $this->assertSame([], $a->suggest('anywhere')); + $this->assertNull($a->reverse(1.0, 2.0)); + } + + #[Test] + public function a_blank_query_short_circuits_before_a_call(): void + { + $a = new Tiger_Test_AwsAdapter(self::CFG); + $a->canned = ['Results' => []]; + $this->assertSame([], $a->suggest(' ')); + $this->assertSame([], $a->geocode(' ')); + $this->assertSame([], $a->calls, 'no request built for a blank query'); + } + + // -- suggest / geocode / reverse parsing (stubbed _call) --------------------------------------- + + #[Test] + public function suggest_maps_prediction_results_and_skips_textless_ones(): void + { + $a = new Tiger_Test_AwsAdapter(self::CFG); + $a->canned = ['Results' => [ + ['Text' => '123 Main St, Reno, NV', 'PlaceId' => 'PID1'], + ['PlaceId' => 'PID2'], // no Text — skipped + ]]; + + $out = $a->suggest('123 Main'); + $this->assertCount(1, $out); + $this->assertSame('123 Main St, Reno, NV', $out[0]->label); + $this->assertSame('PID1', $out[0]->id); + $this->assertSame('suggestion', $out[0]->type); + $this->assertSame('search/suggestions', $a->calls[0]['op']); + } + + #[Test] + public function geocode_maps_a_place_result(): void + { + $a = new Tiger_Test_AwsAdapter(self::CFG); + $a->canned = ['Results' => [['Place' => [ + 'Label' => '1 Infinite Loop, Cupertino, CA 95014, USA', + 'AddressNumber' => '1', + 'Street' => 'Infinite Loop', + 'Municipality' => 'Cupertino', + 'Region' => 'California', + 'PostalCode' => '95014', + 'Country' => 'USA', + 'Geometry' => ['Point' => [-122.0312, 37.3318]], + ]]]]; + + $p = $a->geocode('1 Infinite Loop')[0]; + $this->assertSame('1 Infinite Loop', $p->line1, 'line1 = "AddressNumber Street"'); + $this->assertSame('Cupertino', $p->city); + $this->assertSame('California', $p->region); + $this->assertSame('95014', $p->postal); + $this->assertSame('US', $p->country, 'alpha-3 USA mapped to alpha-2 US'); + $this->assertSame(37.3318, $p->latitude, 'Point is [lng, lat] — latitude is the 2nd element'); + $this->assertSame(-122.0312, $p->longitude); + $this->assertSame('address', $p->type); + $this->assertSame('search/text', $a->calls[0]['op']); + } + + #[Test] + public function an_unknown_alpha3_country_passes_through_unchanged(): void + { + $a = new Tiger_Test_AwsAdapter(self::CFG); + $a->canned = ['Results' => [['Place' => ['Country' => 'XYZ', 'Geometry' => ['Point' => [0, 0]]]]]]; + $this->assertSame('XYZ', $a->geocode('x')[0]->country); + } + + #[Test] + public function reverse_sends_position_as_lng_lat_and_maps_the_first_result(): void + { + $a = new Tiger_Test_AwsAdapter(self::CFG); + $a->canned = ['Results' => [['Place' => ['Municipality' => 'Oslo', 'Country' => 'NOR', 'Geometry' => ['Point' => [10.75, 59.91]]]]]]; + + $p = $a->reverse(59.91, 10.75); + $this->assertSame('Oslo', $p->city); + $this->assertSame('NO', $p->country); + $this->assertSame('search/position', $a->calls[0]['op']); + $this->assertSame([10.75, 59.91], $a->calls[0]['body']['Position'], 'AWS wants [longitude, latitude]'); + } + + #[Test] + public function reverse_returns_null_when_there_is_no_place(): void + { + $a = new Tiger_Test_AwsAdapter(self::CFG); + $a->canned = ['Results' => []]; + $this->assertNull($a->reverse(1.0, 2.0)); + } + + // -- _textBody (country bias → alpha-3 FilterCountries) ---------------------------------------- + + #[Test] + public function the_text_body_adds_a_country_filter_in_alpha3(): void + { + $a = new Tiger_Location_Adapter_Aws(self::CFG); + $m = new ReflectionMethod($a, '_textBody'); + + $plain = $m->invoke($a, 'reno', []); + $this->assertSame(['Text' => 'reno', 'MaxResults' => 5], $plain); + $this->assertArrayNotHasKey('FilterCountries', $plain); + + $biased = $m->invoke($a, 'reno', ['country' => 'US']); + $this->assertSame(['USA'], $biased['FilterCountries'], 'country biased as ISO alpha-3'); + } + + // -- SigV4 (re-derived independently, no clock freeze) ----------------------------------------- + + #[Test] + public function sigv4_headers_are_well_formed_and_the_signature_verifies(): void + { + $a = new Tiger_Location_Adapter_Aws(self::CFG); + $m = new ReflectionMethod($a, '_sigV4Headers'); + + $host = 'places.geo.us-east-1.amazonaws.com'; + $region = 'us-east-1'; + $path = '/places/v0/indexes/MyIndex/search/text'; + $body = '{"Text":"reno","MaxResults":5}'; + $key = 'AKIA_TEST'; + $secret = 'SECRET_TEST'; + + $headers = $m->invoke($a, $host, $region, $path, $body, $key, $secret, ''); + $map = $this->headerMap($headers); + + // structural invariants + $this->assertSame(hash('sha256', $body), $map['x-amz-content-sha256']); + $this->assertSame($host, $map['host']); + $this->assertArrayNotHasKey('x-amz-security-token', $map, 'no token header when no session token'); + + $auth = $map['authorization']; + $this->assertStringStartsWith('AWS4-HMAC-SHA256 ', $auth); + $this->assertStringContainsString('SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date', $auth); + $this->assertStringContainsString('Credential=' . $key . '/' . substr($map['x-amz-date'], 0, 8) . '/us-east-1/geo/aws4_request', $auth); + + // independent re-derivation using the exact timestamp the method emitted + $expected = $this->deriveSignature($host, $region, $path, $body, $secret, $map['x-amz-date'], ''); + $this->assertStringContainsString('Signature=' . $expected, $auth, 'the SigV4 math matches an independent implementation'); + } + + #[Test] + public function a_session_token_is_signed_in_and_sent(): void + { + $a = new Tiger_Location_Adapter_Aws(self::CFG); + $m = new ReflectionMethod($a, '_sigV4Headers'); + + $host = 'places.geo.us-east-1.amazonaws.com'; + $body = '{}'; + $headers = $m->invoke($a, $host, 'us-east-1', '/p', $body, 'K', 'S', 'TOKEN123'); + $map = $this->headerMap($headers); + + $this->assertSame('TOKEN123', $map['x-amz-security-token']); + $this->assertStringContainsString('SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token', $map['authorization']); + + $expected = $this->deriveSignature($host, 'us-east-1', '/p', $body, 'S', $map['x-amz-date'], 'TOKEN123'); + $this->assertStringContainsString('Signature=' . $expected, $map['authorization']); + } + + // -- helpers ----------------------------------------------------------------------------------- + + /** Split a curl "Header: value" list into a lowercased-name map. */ + private function headerMap(array $headers): array + { + $out = []; + foreach ($headers as $h) { + [$name, $val] = array_pad(explode(':', $h, 2), 2, ''); + $out[strtolower(trim($name))] = trim($val); + } + return $out; + } + + /** A standalone SigV4 signature for the same canonical request the adapter builds. */ + private function deriveSignature(string $host, string $region, string $path, string $body, string $secret, string $now, string $token): string + { + $date = substr($now, 0, 8); + $bodyHash = hash('sha256', $body); + $canonicalHeaders = "content-type:application/json\n" + . "host:{$host}\n" + . "x-amz-content-sha256:{$bodyHash}\n" + . "x-amz-date:{$now}\n"; + $signed = 'content-type;host;x-amz-content-sha256;x-amz-date'; + if ($token !== '') { + $canonicalHeaders .= "x-amz-security-token:{$token}\n"; + $signed .= ';x-amz-security-token'; + } + $canonicalRequest = "POST\n{$path}\n\n{$canonicalHeaders}\n{$signed}\n{$bodyHash}"; + $scope = $date . '/' . $region . '/geo/aws4_request'; + $stringToSign = "AWS4-HMAC-SHA256\n{$now}\n{$scope}\n" . hash('sha256', $canonicalRequest); + + $kDate = hash_hmac('sha256', $date, 'AWS4' . $secret, true); + $kRegion = hash_hmac('sha256', $region, $kDate, true); + $kService = hash_hmac('sha256', 'geo', $kRegion, true); + $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true); + return hash_hmac('sha256', $stringToSign, $kSigning); + } +} diff --git a/tests/Unit/Location/FacadeTest.php b/tests/Unit/Location/FacadeTest.php new file mode 100644 index 0000000..6724d5c --- /dev/null +++ b/tests/Unit/Location/FacadeTest.php @@ -0,0 +1,346 @@ + the built-in registry, restored after each test */ + private array $registrySnapshot; + + protected function setUp(): void + { + parent::setUp(); + Tiger_Test_LocationAdapter::reset(); + $prop = new ReflectionProperty(Tiger_Location::class, '_adapters'); + $this->registrySnapshot = $prop->getValue(); + } + + protected function tearDown(): void + { + $prop = new ReflectionProperty(Tiger_Location::class, '_adapters'); + $prop->setValue(null, $this->registrySnapshot); + Tiger_Test_LocationAdapter::reset(); + parent::tearDown(); + } + + /** Seed config selecting the test double for both operation groups, plus its adapter config. */ + private function seedTestProvider(array $adapterCfg = []): void + { + Tiger_Location::register('test', Tiger_Test_LocationAdapter::class); + $this->setConfig(['tiger' => ['location' => [ + 'address' => ['provider' => 'test'], + 'ip' => ['provider' => 'test'], + 'adapters' => ['test' => $adapterCfg], + 'cache_ttl' => 0, // keep the IP cache out of the way + ]]]); + } + + // -- registration ------------------------------------------------------------------------------ + + #[Test] + public function register_is_case_insensitive_on_the_provider_name(): void + { + Tiger_Location::register('MyProv', Tiger_Test_LocationAdapter::class); + $prop = new ReflectionProperty(Tiger_Location::class, '_adapters'); + $this->assertArrayHasKey('myprov', $prop->getValue(), 'the name is lower-cased on register'); + } + + // -- suggest ----------------------------------------------------------------------------------- + + #[Test] + public function suggest_returns_empty_for_a_blank_query_without_touching_a_provider(): void + { + // no config at all — a blank query must short-circuit before any provider lookup. + $this->assertSame([], (new Tiger_Location())->suggest(' ')); + } + + #[Test] + public function suggest_returns_empty_when_no_address_provider_is_configured(): void + { + $this->setConfig(['tiger' => ['location' => []]]); + $this->assertSame([], (new Tiger_Location())->suggest('1 Infinite Loop')); + } + + #[Test] + public function suggest_routes_to_the_configured_capable_adapter(): void + { + $this->seedTestProvider(); + Tiger_Test_LocationAdapter::$suggestReturn = [new Tiger_Location_Place(['label' => 'A']), new Tiger_Location_Place(['label' => 'B'])]; + + $out = (new Tiger_Location())->suggest('Main St'); + $this->assertCount(2, $out); + $this->assertSame('A', $out[0]->label); + } + + #[Test] + public function suggest_returns_empty_when_the_adapter_lacks_the_capability(): void + { + $this->seedTestProvider(); + Tiger_Test_LocationAdapter::$caps = ['ip']; // does not support suggest + Tiger_Test_LocationAdapter::$suggestReturn = [new Tiger_Location_Place(['label' => 'nope'])]; + + $this->assertSame([], (new Tiger_Location())->suggest('Main St')); + } + + #[Test] + public function suggest_swallows_a_provider_error_into_an_empty_list(): void + { + $this->seedTestProvider(); + Tiger_Test_LocationAdapter::$throw = true; + $this->assertSame([], (new Tiger_Location())->suggest('Main St')); + } + + // -- geocode ----------------------------------------------------------------------------------- + + #[Test] + public function geocode_routes_and_degrades_gracefully(): void + { + $this->seedTestProvider(); + Tiger_Test_LocationAdapter::$geocodeReturn = [new Tiger_Location_Place(['city' => 'Reno'])]; + $this->assertSame('Reno', (new Tiger_Location())->geocode('Reno NV')[0]->city); + + Tiger_Test_LocationAdapter::$throw = true; + $this->assertSame([], (new Tiger_Location())->geocode('Reno NV')); + + $this->assertSame([], (new Tiger_Location())->geocode(' '), 'blank short-circuits'); + } + + // -- reverse ----------------------------------------------------------------------------------- + + #[Test] + public function reverse_returns_the_place_or_null_on_error_or_unconfigured(): void + { + $this->seedTestProvider(); + Tiger_Test_LocationAdapter::$reverseReturn = new Tiger_Location_Place(['city' => 'Oslo']); + $this->assertSame('Oslo', (new Tiger_Location())->reverse(59.91, 10.75)->city); + + Tiger_Test_LocationAdapter::$throw = true; + $this->assertNull((new Tiger_Location())->reverse(59.91, 10.75)); + + $this->setConfig(['tiger' => ['location' => []]]); + $this->assertNull((new Tiger_Location())->reverse(59.91, 10.75), 'no provider => null'); + } + + // -- ip ---------------------------------------------------------------------------------------- + + #[Test] + public function ip_rejects_a_blank_or_invalid_address_before_any_provider(): void + { + $svc = new Tiger_Location(); + $this->assertNull($svc->ip(' ')); + $this->assertNull($svc->ip('not-an-ip')); + $this->assertNull($svc->ip('999.999.1.1')); + } + + #[Test] + public function ip_routes_a_valid_address_to_the_configured_provider(): void + { + $this->seedTestProvider(); + Tiger_Test_LocationAdapter::$ipReturn = new Tiger_Location_Place(['country' => 'US', 'ip' => '8.8.8.8']); + $place = (new Tiger_Location())->ip('8.8.8.8'); + $this->assertNotNull($place); + $this->assertSame('US', $place->country); + } + + #[Test] + public function ip_returns_null_when_the_provider_throws(): void + { + $this->seedTestProvider(); + Tiger_Test_LocationAdapter::$throw = true; + $this->assertNull((new Tiger_Location())->ip('8.8.8.8')); + } + + // -- adapter resolution edge cases ------------------------------------------------------------- + + #[Test] + public function an_unknown_provider_name_resolves_to_no_adapter(): void + { + $this->setConfig(['tiger' => ['location' => ['address' => ['provider' => 'ghost']]]]); + $this->assertSame([], (new Tiger_Location())->suggest('x')); + } + + #[Test] + public function a_registered_class_that_is_not_an_adapter_resolves_to_null(): void + { + Tiger_Location::register('bogus', 'Tiger_Test_NotAnAdapter'); + $this->setConfig(['tiger' => ['location' => ['address' => ['provider' => 'bogus']]]]); + $this->assertSame([], (new Tiger_Location())->suggest('x')); + } + + #[Test] + public function the_adapter_instance_is_reused_within_one_facade(): void + { + $this->seedTestProvider(); + $svc = new Tiger_Location(); + Tiger_Test_LocationAdapter::$geocodeReturn = [new Tiger_Location_Place(['city' => 'A'])]; + $svc->geocode('q'); + // A second op on the same facade must not construct a new instance (cached in $_instances). + $prop = new ReflectionProperty(Tiger_Location::class, '_instances'); + $this->assertArrayHasKey('test', $prop->getValue($svc)); + } + + // -- decrypt-secrets seam (adapter config) ----------------------------------------------------- + + #[Test] + public function encrypted_adapter_secrets_are_decrypted_before_reaching_the_adapter(): void + { + // seed a crypto key so Tiger_Crypto::isConfigured() is true, then hand the adapter an + // encrypted `key_enc`; the facade must decrypt it to a plaintext `key` for the adapter. + Tiger_Location::register('test', Tiger_Test_LocationAdapter::class); + $key = base64_encode(str_repeat("\x11", 32)); + $this->setConfig(['tiger' => ['crypto' => ['key' => $key]]]); // register the key first… + $enc = Tiger_Crypto::encrypt('s3cr3t'); // …so encrypt() can read it + $this->setConfig(['tiger' => [ + 'crypto' => ['key' => $key], + 'location' => [ + 'address' => ['provider' => 'test'], + 'adapters' => ['test' => ['key_enc' => $enc]], + ], + ]]); + + Tiger_Test_LocationAdapter::$geocodeReturn = []; + (new Tiger_Location())->geocode('q'); // forces construction + + $this->assertSame('s3cr3t', Tiger_Test_LocationAdapter::$seenConfig['key'] ?? null, + 'the _enc secret is decrypted to its plaintext field for the adapter'); + } + + // -- admin surface: adapters() ----------------------------------------------------------------- + + #[Test] + public function adapters_lists_the_registered_built_ins_with_their_structure(): void + { + $this->setConfig(['tiger' => ['location' => []]]); + $all = Tiger_Location::adapters(); + + $this->assertArrayHasKey('nominatim', $all); + $this->assertArrayHasKey('aws', $all); + $this->assertArrayHasKey('ipapi', $all); + $this->assertSame('Nominatim (OpenStreetMap)', $all['nominatim']['label']); + $this->assertContains(Tiger_Location_Adapter_Interface::CAP_SUGGEST, $all['nominatim']['caps']); + $this->assertIsArray($all['nominatim']['fields']); + } + + #[Test] + public function adapters_filters_to_a_single_capability(): void + { + $this->setConfig(['tiger' => ['location' => []]]); + $ipCapable = Tiger_Location::adapters(Tiger_Location_Adapter_Interface::CAP_IP); + + $this->assertArrayHasKey('ipapi', $ipCapable, 'ipapi is the IP provider'); + $this->assertArrayNotHasKey('nominatim', $ipCapable, 'nominatim does not do IP'); + } + + // -- admin surface: settings() ----------------------------------------------------------------- + + #[Test] + public function settings_reports_provider_defaults_and_masks_secret_fields(): void + { + $this->setConfig(['tiger' => ['location' => [ + 'adapters' => ['aws' => ['region' => 'us-east-1', 'secret' => 'shh']], + ]]]); + $s = Tiger_Location::settings(); + + $this->assertSame('ipapi', $s['ip_provider']); + $this->assertSame('nominatim', $s['address_provider']); + $this->assertSame(86400, $s['cache_ttl']); + // a text field surfaces its value; a secret field surfaces only a `has` flag (never the secret). + $this->assertSame('us-east-1', $s['values']['aws']['region']['value']); + $this->assertTrue($s['values']['aws']['secret']['has']); + $this->assertArrayNotHasKey('value', $s['values']['aws']['secret']); + } + + // -- admin surface: test() --------------------------------------------------------------------- + + #[Test] + public function test_rejects_an_invalid_ip(): void + { + $r = Tiger_Location::test('nope', 'ipapi'); + $this->assertFalse($r['ok']); + $this->assertStringContainsString('valid IP', $r['error']); + } + + #[Test] + public function test_reports_an_unknown_provider(): void + { + $r = Tiger_Location::test('8.8.8.8', 'does-not-exist'); + $this->assertFalse($r['ok']); + $this->assertSame('Unknown provider.', $r['error']); + } + + #[Test] + public function test_reports_a_provider_that_cannot_do_ip(): void + { + Tiger_Location::register('test', Tiger_Test_LocationAdapter::class); + Tiger_Test_LocationAdapter::$caps = ['suggest']; // not CAP_IP + $this->setConfig(['tiger' => ['location' => ['adapters' => ['test' => []]]]]); + + $r = Tiger_Location::test('8.8.8.8', 'test'); + $this->assertFalse($r['ok']); + $this->assertStringContainsString('does not support IP', $r['error']); + } + + #[Test] + public function test_returns_the_place_fields_on_a_successful_lookup(): void + { + Tiger_Location::register('test', Tiger_Test_LocationAdapter::class); + Tiger_Test_LocationAdapter::$ipReturn = new Tiger_Location_Place(['country' => 'US', 'city' => 'Reno', 'label' => 'Reno, US']); + $this->setConfig(['tiger' => ['location' => ['adapters' => ['test' => []]]]]); + + $r = Tiger_Location::test('8.8.8.8', 'test', ['endpoint' => 'http://example/']); + $this->assertTrue($r['ok']); + $this->assertSame('US', $r['country']); + $this->assertSame('Reno', $r['city']); + // a non-empty form override is threaded into the adapter's config. + $this->assertSame('http://example/', Tiger_Test_LocationAdapter::$seenConfig['endpoint']); + } + + #[Test] + public function test_surfaces_a_provider_exception_as_an_error(): void + { + Tiger_Location::register('test', Tiger_Test_LocationAdapter::class); + Tiger_Test_LocationAdapter::$throw = true; + $this->setConfig(['tiger' => ['location' => ['adapters' => ['test' => []]]]]); + + $r = Tiger_Location::test('8.8.8.8', 'test'); + $this->assertFalse($r['ok']); + $this->assertSame('boom', $r['error']); + } + + #[Test] + public function test_reports_no_result_when_the_provider_places_nothing(): void + { + Tiger_Location::register('test', Tiger_Test_LocationAdapter::class); + Tiger_Test_LocationAdapter::$ipReturn = null; + $this->setConfig(['tiger' => ['location' => ['adapters' => ['test' => []]]]]); + + $r = Tiger_Location::test('8.8.8.8', 'test'); + $this->assertFalse($r['ok']); + $this->assertStringContainsString('No result', $r['error']); + } +} diff --git a/tests/Unit/Location/IpApiTest.php b/tests/Unit/Location/IpApiTest.php new file mode 100644 index 0000000..f139fce --- /dev/null +++ b/tests/Unit/Location/IpApiTest.php @@ -0,0 +1,130 @@ + 'success', + 'country' => 'United States', + 'countryCode' => 'US', + 'region' => 'NV', + 'regionName' => 'Nevada', + 'city' => 'Reno', + 'zip' => '89501', + 'lat' => 39.5296, + 'lon' => -119.8138, + 'query' => '8.8.8.8', + ], $overrides); + } + + #[Test] + public function capabilities_and_label(): void + { + $a = new Tiger_Location_Adapter_IpApi(); + $this->assertSame(['ip'], $a->capabilities()); + $this->assertSame('ip-api.com', $a->label()); + $this->assertNotEmpty($a->fields()); + } + + #[Test] + public function ip_maps_a_success_body_into_a_normalized_place(): void + { + $a = new Tiger_Test_IpApiAdapter(); + $a->canned = $this->successBody(); + + $p = $a->ip('8.8.8.8'); + $this->assertNotNull($p); + $this->assertSame('Reno', $p->city); + $this->assertSame('Nevada', $p->region); + $this->assertSame('89501', $p->postal); + $this->assertSame('US', $p->country); + $this->assertSame(39.5296, $p->latitude); + $this->assertSame(-119.8138, $p->longitude); + $this->assertSame('8.8.8.8', $p->ip); + $this->assertSame('Reno, Nevada US', $p->label); + $this->assertSame('ip', $p->type); + $this->assertSame('ipapi', $p->source); + } + + #[Test] + public function the_request_url_carries_the_fixed_field_set(): void + { + $a = new Tiger_Test_IpApiAdapter(); + $a->canned = $this->successBody(); + $a->ip('8.8.8.8'); + + $url = $a->urls[0]; + $this->assertStringContainsString('http://ip-api.com/json/8.8.8.8?fields=', $url); + $this->assertStringContainsString('countryCode', $url); + $this->assertStringNotContainsString('&key=', $url, 'no key appended when none configured'); + } + + #[Test] + public function a_pro_key_is_appended_to_the_url(): void + { + $a = new Tiger_Test_IpApiAdapter(['key' => 'PRO-123']); + $a->canned = $this->successBody(); + $a->ip('8.8.8.8'); + + $this->assertStringContainsString('&key=PRO-123', $a->urls[0]); + } + + #[Test] + public function a_non_success_body_returns_null(): void + { + $a = new Tiger_Test_IpApiAdapter(); + $a->canned = ['status' => 'fail', 'message' => 'private range']; + $this->assertNull($a->ip('10.0.0.1')); + } + + #[Test] + public function a_null_transport_result_returns_null(): void + { + $a = new Tiger_Test_IpApiAdapter(); + $a->canned = null; + $this->assertNull($a->ip('8.8.8.8')); + } + + #[Test] + public function a_blank_ip_or_blank_endpoint_short_circuits_to_null(): void + { + $a = new Tiger_Test_IpApiAdapter(); + $a->canned = $this->successBody(); + $this->assertNull($a->ip(''), 'blank IP => null'); + $this->assertSame([], $a->urls, 'no request was built for a blank IP'); + + $b = new Tiger_Test_IpApiAdapter(['endpoint' => '']); + $b->canned = $this->successBody(); + $this->assertNull($b->ip('8.8.8.8'), 'blank endpoint => null'); + } + + #[Test] + public function the_ip_falls_back_to_the_argument_when_the_body_omits_query(): void + { + $a = new Tiger_Test_IpApiAdapter(); + $a->canned = $this->successBody(['query' => null]); + $this->assertSame('8.8.8.8', $a->ip('8.8.8.8')->ip); + } +} diff --git a/tests/Unit/Location/NominatimTest.php b/tests/Unit/Location/NominatimTest.php new file mode 100644 index 0000000..9bf40b9 --- /dev/null +++ b/tests/Unit/Location/NominatimTest.php @@ -0,0 +1,146 @@ + 12345, + 'display_name' => '1 Infinite Loop, Cupertino, CA, USA', + 'lat' => '37.3318', + 'lon' => '-122.0312', + 'addresstype' => 'house', + 'type' => 'house', + 'address' => [ + 'house_number' => '1', + 'road' => 'Infinite Loop', + 'city' => 'Cupertino', + 'state' => 'California', + 'postcode' => '95014', + 'country_code' => 'us', + ], + ], $overrides); + } + + #[Test] + public function capabilities_and_label(): void + { + $a = new Tiger_Location_Adapter_Nominatim(); + $this->assertSame(['suggest', 'geocode', 'reverse'], $a->capabilities()); + $this->assertSame('Nominatim (OpenStreetMap)', $a->label()); + $this->assertNotEmpty($a->fields()); + } + + #[Test] + public function suggest_parses_a_search_result_into_a_normalized_place(): void + { + $a = new Tiger_Test_NominatimAdapter(); + $a->canned = [$this->row()]; + + $out = $a->suggest('1 Infinite'); + $this->assertCount(1, $out); + $p = $out[0]; + $this->assertSame('12345', $p->id, 'place_id stringified'); + $this->assertSame('1 Infinite Loop', $p->line1); + $this->assertSame('Cupertino', $p->city); + $this->assertSame('California', $p->region); + $this->assertSame('95014', $p->postal); + $this->assertSame('US', $p->country, 'country_code upper-cased to alpha-2'); + $this->assertSame(37.3318, $p->latitude); + $this->assertSame(-122.0312, $p->longitude); + $this->assertSame('house', $p->type); + $this->assertSame('nominatim', $p->source); + $this->assertSame($this->row(), $p->raw); + } + + #[Test] + public function the_city_falls_back_through_town_village_hamlet_municipality(): void + { + $a = new Tiger_Test_NominatimAdapter(); + $a->canned = [$this->row(['address' => ['town' => 'Smallville', 'road' => 'Main']])]; + $this->assertSame('Smallville', $a->geocode('x')[0]->city); + + $a->canned = [$this->row(['address' => ['municipality' => 'Metroville']])]; + $this->assertSame('Metroville', $a->geocode('x')[0]->city); + } + + #[Test] + public function a_missing_street_leaves_line1_null(): void + { + $a = new Tiger_Test_NominatimAdapter(); + $a->canned = [$this->row(['address' => ['city' => 'Nowhere']])]; + $this->assertNull($a->geocode('x')[0]->line1, 'no house_number/road => null, not an empty string'); + } + + #[Test] + public function the_search_url_carries_the_query_country_bias_and_limit(): void + { + $a = new Tiger_Test_NominatimAdapter(); + $a->canned = []; + $a->suggest('main st', ['country' => 'US', 'limit' => 99]); + + $url = $a->urls[0]; + $this->assertStringContainsString('https://nominatim.openstreetmap.org/search?', $url); + $this->assertStringContainsString('format=jsonv2', $url); + $this->assertStringContainsString('addressdetails=1', $url); + $this->assertStringContainsString('countrycodes=us', $url, 'country lower-cased for the bias'); + $this->assertStringContainsString('limit=10', $url, 'limit clamps to 10'); + } + + #[Test] + public function a_configured_endpoint_and_email_shape_the_url(): void + { + $a = new Tiger_Test_NominatimAdapter(['endpoint' => 'https://osm.example/nominatim/', 'email' => 'ops@example.com']); + $a->canned = []; + $a->geocode('paris'); + + $url = $a->urls[0]; + $this->assertStringContainsString('https://osm.example/nominatim/search?', $url, 'trailing slash trimmed then /search appended'); + $this->assertStringContainsString('email=ops%40example.com', $url); + } + + #[Test] + public function reverse_maps_a_single_result_and_returns_null_when_unplaceable(): void + { + $a = new Tiger_Test_NominatimAdapter(); + $a->canned = $this->row(); + $place = $a->reverse(37.33, -122.03); + $this->assertNotNull($place); + $this->assertSame('Cupertino', $place->city); + $this->assertStringContainsString('/reverse?', $a->urls[0]); + + $a2 = new Tiger_Test_NominatimAdapter(); + $a2->canned = ['no' => 'address key here']; + $this->assertNull($a2->reverse(0.0, 0.0), 'a result without an address => null'); + } + + #[Test] + public function a_null_search_response_yields_an_empty_list(): void + { + $a = new Tiger_Test_NominatimAdapter(); + $a->canned = null; // transport/parse failure upstream + $this->assertSame([], $a->suggest('x')); + } +} diff --git a/tests/Unit/Media/ImageTransformTest.php b/tests/Unit/Media/ImageTransformTest.php new file mode 100644 index 0000000..e4240b5 --- /dev/null +++ b/tests/Unit/Media/ImageTransformTest.php @@ -0,0 +1,223 @@ +markTestSkipped('GD is not available in this PHP build.'); + } + } + + protected function tearDown(): void + { + foreach ($this->tmp as $p) { + @unlink($p); + } + parent::tearDown(); + } + + /** Synthesize a real w×h image on disk in the given format; returns its path (tracked). */ + private function source(int $w, int $h, string $format): string + { + $img = imagecreatetruecolor($w, $h); + imagefilledrectangle($img, 0, 0, $w - 1, $h - 1, imagecolorallocate($img, 10, 120, 200)); + $path = tempnam(sys_get_temp_dir(), 'tsrc') . '.' . $format; + match ($format) { + 'jpg' => imagejpeg($img, $path, 90), + 'gif' => imagegif($img, $path), + 'webp' => imagewebp($img, $path, 90), + default => imagepng($img, $path), + }; + $this->tmp[] = $path; + return $path; + } + + private function track(array $variants): array + { + foreach ($variants as $v) { + if (isset($v['path'])) { + $this->tmp[] = $v['path']; + } + } + return $variants; + } + + /** Assert the emitted variant file is a real image of the expected type + pixel size. */ + private function assertValidVariant(array $variant, int $expW, int $expH, int $expType): void + { + $this->assertFileExists($variant['path']); + $info = getimagesize($variant['path']); + $this->assertIsArray($info, 'the emitted variant is a decodable image'); + $this->assertSame($expType, $info[2], 'the encoder wrote the expected image format'); + $this->assertSame($expW, $info[0], 'width matches the computed contain-fit width'); + $this->assertSame($expH, $info[1], 'height matches the computed contain-fit height'); + $this->assertSame($expW, $variant['width']); + $this->assertSame($expH, $variant['height']); + } + + // ---- JPEG: the lossy encoder + the non-alpha canvas + the EXIF orient hook ----------------- + + #[Test] + public function jpeg_source_produces_a_valid_downscaled_jpeg(): void + { + if (!Tiger_Media_Image::supports('image/jpeg')) { + $this->markTestSkipped('GD JPEG support absent.'); + } + // 200×100 → edge 100 → 100×50. Runs _load(jpeg) → _orient(no EXIF, no rotate) → _canvas(opaque) + // → imagecopyresampled → _save(imagejpeg). + $src = $this->source(200, 100, 'jpg'); + $out = $this->track(Tiger_Media_Image::variants($src, 'image/jpeg', ['m' => 100])); + + $this->assertArrayHasKey('m', $out); + $this->assertSame('image/jpeg', $out['m']['mime']); + $this->assertValidVariant($out['m'], 100, 50, IMAGETYPE_JPEG); + } + + #[Test] + public function jpeg_quality_argument_is_clamped_out_of_range(): void + { + if (!Tiger_Media_Image::supports('image/jpeg')) { + $this->markTestSkipped('GD JPEG support absent.'); + } + // quality 5000 is clamped to 100 inside _save (max(1,min(100,q))); a valid file still results. + $src = $this->source(200, 100, 'jpg'); + $high = $this->track(Tiger_Media_Image::variants($src, 'image/jpeg', ['m' => 100], 5000)); + $this->assertArrayHasKey('m', $high); + $this->assertValidVariant($high['m'], 100, 50, IMAGETYPE_JPEG); + + // quality -20 is clamped up to 1 (still encodes). + $low = $this->track(Tiger_Media_Image::variants($src, 'image/jpeg', ['m' => 100], -20)); + $this->assertArrayHasKey('m', $low); + $this->assertValidVariant($low['m'], 100, 50, IMAGETYPE_JPEG); + } + + // ---- GIF: the alpha canvas arm + the gif encoder ------------------------------------------- + + #[Test] + public function gif_source_produces_a_valid_downscaled_gif(): void + { + if (!Tiger_Media_Image::supports('image/gif')) { + $this->markTestSkipped('GD GIF support absent.'); + } + // gif goes through the transparent _canvas arm and imagegif(). + $src = $this->source(160, 80, 'gif'); + $out = $this->track(Tiger_Media_Image::variants($src, 'image/gif', ['t' => 80])); + + $this->assertArrayHasKey('t', $out); + $this->assertSame('image/gif', $out['t']['mime']); + $this->assertValidVariant($out['t'], 80, 40, IMAGETYPE_GIF); + } + + // ---- WebP: the lossy encoder via the alpha canvas arm -------------------------------------- + + #[Test] + public function webp_source_produces_a_valid_downscaled_webp(): void + { + if (!Tiger_Media_Image::supports('image/webp')) { + $this->markTestSkipped('GD WebP support absent.'); + } + $src = $this->source(200, 200, 'webp'); + $out = $this->track(Tiger_Media_Image::variants($src, 'image/webp', ['s' => 100])); + + $this->assertArrayHasKey('s', $out); + $this->assertSame('image/webp', $out['s']['mime']); + $this->assertValidVariant($out['s'], 100, 100, IMAGETYPE_WEBP); + } + + // ---- PNG: the lossless encoder keeps a valid, transparent-capable file --------------------- + + #[Test] + public function png_source_produces_a_valid_downscaled_png(): void + { + if (!Tiger_Media_Image::supports('image/png')) { + $this->markTestSkipped('GD PNG support absent.'); + } + // png uses the fixed compression level (quality is ignored for the lossless encoder). + $src = $this->source(300, 150, 'png'); + $out = $this->track(Tiger_Media_Image::variants($src, 'image/png', ['h' => 150], 10)); + + $this->assertArrayHasKey('h', $out); + $this->assertValidVariant($out['h'], 150, 75, IMAGETYPE_PNG); + } + + // ---- Multiple presets in one call: only the sub-source ones emit, each valid --------------- + + #[Test] + public function multiple_presets_emit_only_the_downscales_each_a_valid_file(): void + { + if (!Tiger_Media_Image::supports('image/jpeg')) { + $this->markTestSkipped('GD JPEG support absent.'); + } + // Source long edge = 400. thumbnail(50) + small(200) run; large(800) is an upscale → skipped. + $src = $this->source(400, 300, 'jpg'); + $out = $this->track(Tiger_Media_Image::variants( + $src, + 'image/jpeg', + ['thumbnail' => 50, 'small' => 200, 'large' => 800] + )); + + $this->assertArrayNotHasKey('large', $out); + $this->assertValidVariant($out['thumbnail'], 50, 38, IMAGETYPE_JPEG); // 400→50 = 0.125 → 300*0.125=37.5→38 + $this->assertValidVariant($out['small'], 200, 150, IMAGETYPE_JPEG); + } + + // ---- _load returns false for a corrupt / non-image source → [] ----------------------------- + + #[Test] + public function a_corrupt_source_of_a_supported_mime_yields_no_variants(): void + { + if (!Tiger_Media_Image::supports('image/png')) { + $this->markTestSkipped('GD PNG support absent.'); + } + // A supported MIME but the bytes aren't a real PNG → _load()'s @imagecreatefrompng returns + // false → variants() short-circuits to [] (no warning surfaced, no crash). + $bogus = tempnam(sys_get_temp_dir(), 'tbad') . '.png'; + file_put_contents($bogus, 'this is definitely not a PNG'); + $this->tmp[] = $bogus; + + $this->assertSame([], Tiger_Media_Image::variants($bogus, 'image/png', ['t' => 50])); + } + + // ---- supports() tracks the actual GD build for every format -------------------------------- + + #[Test] + public function supports_agrees_with_the_gd_build_for_each_format(): void + { + $gd = gd_info(); + $this->assertSame(!empty($gd['JPEG Support']), Tiger_Media_Image::supports('image/jpeg')); + $this->assertSame(!empty($gd['PNG Support']), Tiger_Media_Image::supports('image/png')); + $this->assertSame(!empty($gd['GIF Read Support']), Tiger_Media_Image::supports('image/gif')); + $this->assertSame(!empty($gd['WebP Support']), Tiger_Media_Image::supports('image/webp')); + } +} diff --git a/tests/Unit/Media/S3MoreTest.php b/tests/Unit/Media/S3MoreTest.php new file mode 100644 index 0000000..cd8376c --- /dev/null +++ b/tests/Unit/Media/S3MoreTest.php @@ -0,0 +1,148 @@ + 'my-bucket', 'region' => 'us-west-2'] + $overrides); + } + + // ---- public url(): the four shapes of _publicUrl ------------------------------------------- + + #[Test] + public function public_url_defaults_to_the_virtual_hosted_s3_url(): void + { + // No cdn, no endpoint → https://.s3..amazonaws.com/. + $s3 = $this->adapter(); + $this->assertSame( + 'https://my-bucket.s3.us-west-2.amazonaws.com/public/org-1/a.jpg', + $s3->url('org-1/a.jpg', 'public') + ); + } + + #[Test] + public function public_url_prefers_a_cdn_host_when_set(): void + { + $s3 = $this->adapter(['cdn' => 'cdn.example.com/']); // trailing slash trimmed + $this->assertSame('https://cdn.example.com/public/x.png', $s3->url('x.png', 'public')); + } + + #[Test] + public function public_url_encodes_path_segments(): void + { + // Spaces + unicode in the key are rawurlencode()'d per path segment (slashes preserved). + $s3 = $this->adapter(); + $this->assertSame( + 'https://my-bucket.s3.us-west-2.amazonaws.com/public/org-1/my%20photo.jpg', + $s3->url('org-1/my photo.jpg', 'public') + ); + } + + #[Test] + public function public_url_honors_a_path_style_endpoint(): void + { + // use_path_style=1 → //. + $s3 = $this->adapter(['endpoint' => 'https://minio.local:9000', 'use_path_style' => 1]); + $this->assertSame('https://minio.local:9000/my-bucket/public/x.png', $s3->url('x.png', 'public')); + } + + #[Test] + public function public_url_honors_a_virtual_host_endpoint(): void + { + // Virtual-host style (default) → the bucket is spliced in after the scheme. + $s3 = $this->adapter(['endpoint' => 'https://sfo3.digitaloceanspaces.com']); + $this->assertSame('https://my-bucket.sfo3.digitaloceanspaces.com/public/x.png', $s3->url('x.png', 'public')); + } + + // ---- private url(): presign disabled + client-unavailable both yield '' -------------------- + + #[Test] + public function private_url_is_empty_when_presigning_is_disabled(): void + { + // presign_ttl=0 → url() returns '' up front (no client), so the media layer streams instead. + $s3 = $this->adapter(['presign_ttl' => 0]); + $this->assertSame('', $s3->url('org-1/book.epub', 'private')); + } + + #[Test] + public function private_url_falls_back_to_empty_when_the_client_cannot_be_built(): void + { + // Presigning enabled, but the AWS SDK is absent → createPresignedRequest can't run → the + // try/catch returns '' (fall back to the ACL-checked streamer route), never throwing. + $s3 = $this->adapter(['presign_ttl' => 600]); + $this->assertSame('', $s3->url('org-1/book.epub', 'private')); + } + + // ---- best-effort readers degrade when the client is unavailable ---------------------------- + + #[Test] + public function exists_is_false_and_size_is_zero_without_a_client(): void + { + $s3 = $this->adapter(); + $this->assertFalse($s3->exists('org-1/a.jpg', 'public')); + $this->assertSame(0, $s3->size('org-1/a.jpg', 'public')); + } + + #[Test] + public function delete_swallows_a_client_failure(): void + { + // Idempotent + best-effort: a delete that can't reach S3 must not throw. + $s3 = $this->adapter(); + $s3->delete('org-1/a.jpg', 'private'); + $this->assertTrue(true, 'delete() returned without throwing'); + } + + #[Test] + public function get_surfaces_a_runtime_exception_without_a_client(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/not found/i'); + $this->adapter()->get('org-1/a.jpg', 'public'); + } + + #[Test] + public function stream_surfaces_a_runtime_exception_without_a_client(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/not found/i'); + $this->adapter()->stream('org-1/a.jpg', 'public'); + } + + // ---- the traversal guard still fires through the public entry points ----------------------- + + #[Test] + public function url_refuses_a_traversal_key(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/invalid key/i'); + $this->adapter()->url('../../etc/passwd', 'public'); + } +} diff --git a/tests/Unit/Media/ScanTest.php b/tests/Unit/Media/ScanTest.php new file mode 100644 index 0000000..61dade8 --- /dev/null +++ b/tests/Unit/Media/ScanTest.php @@ -0,0 +1,198 @@ +sampleFile(); + $r = (new Tiger_Media_Scan())->preStore($file, 'image/png', Tiger_Model_Media::KIND_IMAGE); + @unlink($file); + + $this->assertTrue($r['ok']); + $this->assertSame(Tiger_Model_Media::SCAN_SKIPPED, $r['status']); + $this->assertNull($r['message']); + $this->assertSame([], $r['meta']); + } + + #[Test] + public function pre_store_is_a_no_op_when_every_scanner_is_explicitly_off(): void + { + $this->setConfig(['media' => ['scan' => ['clamav' => 0, 'image' => 0, 'video' => 0]]]); + $file = $this->sampleFile(); + $r = (new Tiger_Media_Scan())->preStore($file, 'image/jpeg', Tiger_Model_Media::KIND_IMAGE); + @unlink($file); + + $this->assertTrue($r['ok']); + $this->assertSame(Tiger_Model_Media::SCAN_SKIPPED, $r['status']); + $this->assertSame([], $r['meta']); + } + + // ---- Fail-open: clamav on but the binary is absent ----------------------------------------- + + #[Test] + public function clamav_on_but_unavailable_fails_open_to_skipped_and_does_not_block(): void + { + // clamdscan/clamscan are absent on the test host (asserted here so the intent is explicit). + $this->assertClamAvAbsent(); + + // A null log sink so the fail-open warning doesn't trip the strict output check. + $this->setConfig(['tiger' => ['log' => ['writer' => 'null']], 'media' => ['scan' => ['clamav' => 1]]]); + Tiger_Log::reset(); + + $file = $this->sampleFile(); + $r = (new Tiger_Media_Scan())->preStore($file, 'application/pdf', Tiger_Model_Media::KIND_DOCUMENT); + @unlink($file); + + // The scanner errored (no binary) → fail-open: the upload is allowed, status stays skipped, + // and the clamav scanner's own verdict is recorded in meta. + $this->assertTrue($r['ok'], 'a scanner ERROR must never block the upload (fail-open)'); + $this->assertSame(Tiger_Model_Media::SCAN_SKIPPED, $r['status']); + $this->assertArrayHasKey('clamav', $r['meta']); + $this->assertSame('error', $r['meta']['clamav']['status']); + } + + // ---- The image-moderation KIND guard ------------------------------------------------------- + + #[Test] + public function image_scanning_on_but_a_non_image_kind_never_runs_moderation(): void + { + // `_on('image')` is true, but kind=VIDEO fails the `&& kind === KIND_IMAGE` guard, so the + // Rekognition scanner is never even constructed (no AWS SDK / no network touched). + $this->setConfig(['media' => ['scan' => ['image' => 1]]]); + + $file = $this->sampleFile(); + $r = (new Tiger_Media_Scan())->preStore($file, 'video/mp4', Tiger_Model_Media::KIND_VIDEO); + @unlink($file); + + $this->assertTrue($r['ok']); + $this->assertSame(Tiger_Model_Media::SCAN_SKIPPED, $r['status']); + $this->assertArrayNotHasKey('image', $r['meta'], 'moderation must not run for a non-image kind'); + } + + #[Test] + public function image_moderation_on_for_an_image_fails_open_when_the_sdk_is_absent(): void + { + // aws/aws-sdk-php is not installed in the test vendor tree, so Rekognition::scan() returns an + // `error` up front (no AWS call) → the orchestrator fail-opens: the upload is allowed, status + // stays skipped, and the moderation verdict is recorded in meta. Exercises the full image arm. + if (class_exists('Aws\\Rekognition\\RekognitionClient')) { + $this->markTestSkipped('aws-sdk is installed — the image fail-open path would make a real call.'); + } + $this->setConfig([ + 'tiger' => ['log' => ['writer' => 'null']], + 'media' => ['scan' => ['image' => 1, 'image_threshold' => 90]], + ]); + Tiger_Log::reset(); + + $file = $this->sampleFile(); + $r = (new Tiger_Media_Scan())->preStore($file, 'image/png', Tiger_Model_Media::KIND_IMAGE); + @unlink($file); + + $this->assertTrue($r['ok'], 'a moderation ERROR must never block the upload (fail-open)'); + $this->assertSame(Tiger_Model_Media::SCAN_SKIPPED, $r['status']); + $this->assertArrayHasKey('image', $r['meta']); + $this->assertSame('error', $r['meta']['image']['status']); + } + + // ---- videoReview() ------------------------------------------------------------------------- + + #[Test] + public function video_review_is_off_by_default_and_tracks_the_config(): void + { + // No config → false. + $this->assertFalse((new Tiger_Media_Scan())->videoReview()); + + // Explicitly on → true. + $this->setConfig(['media' => ['scan' => ['video' => 1]]]); + $this->assertTrue((new Tiger_Media_Scan())->videoReview()); + + // Explicitly off → false. + $this->setConfig(['media' => ['scan' => ['video' => 0]]]); + $this->assertFalse((new Tiger_Media_Scan())->videoReview()); + } + + // ---- Defaults on the protected config helpers ---------------------------------------------- + + #[Test] + public function threshold_defaults_to_eighty_and_reads_the_configured_value(): void + { + $scan = new Tiger_Media_Scan(); + $m = new ReflectionMethod($scan, '_threshold'); + + // No config → the 80.0 default. + $this->assertSame(80.0, $m->invoke($scan)); + + // A configured threshold is used verbatim. + $this->setConfig(['media' => ['scan' => ['image_threshold' => 95]]]); + $this->assertSame(95.0, $m->invoke($scan)); + } + + #[Test] + public function region_is_us_east_1(): void + { + $scan = new Tiger_Media_Scan(); + $m = new ReflectionMethod($scan, '_region'); + $this->assertSame('us-east-1', $m->invoke($scan)); + } + + /** Guard: the fail-open assertions only hold when ClamAV really is absent on this host. */ + private function assertClamAvAbsent(): void + { + foreach (['clamdscan', 'clamscan'] as $bin) { + $out = []; + $code = 1; + exec('command -v ' . escapeshellarg($bin) . ' 2>/dev/null', $out, $code); + if ($code === 0 && !empty($out)) { + $this->markTestSkipped("ClamAV ($bin) is installed on this host — the fail-open path can't be forced."); + } + } + } +} diff --git a/tests/Unit/Module/GithubParseTest.php b/tests/Unit/Module/GithubParseTest.php new file mode 100644 index 0000000..363701e --- /dev/null +++ b/tests/Unit/Module/GithubParseTest.php @@ -0,0 +1,56 @@ +assertSame(['org' => 'WebTigers', 'repo' => 'tiger-core'], Tiger_Module_Github::parseRepo('WebTigers/tiger-core')); + } + + #[Test] + public function parse_repo_accepts_full_https_urls_with_and_without_git_suffix(): void + { + $this->assertSame(['org' => 'acme', 'repo' => 'widget'], Tiger_Module_Github::parseRepo('https://github.com/acme/widget')); + $this->assertSame(['org' => 'acme', 'repo' => 'widget'], Tiger_Module_Github::parseRepo('https://github.com/acme/widget.git')); + $this->assertSame(['org' => 'acme', 'repo' => 'widget'], Tiger_Module_Github::parseRepo('https://github.com/acme/widget/tree/main')); + $this->assertSame(['org' => 'acme', 'repo' => 'widget'], Tiger_Module_Github::parseRepo('git@github.com:acme/widget.git')); + } + + #[Test] + public function parse_repo_returns_null_for_garbage(): void + { + $this->assertNull(Tiger_Module_Github::parseRepo('')); + $this->assertNull(Tiger_Module_Github::parseRepo('not a repo')); + $this->assertNull(Tiger_Module_Github::parseRepo('https://example.com/foo/bar')); + } + + #[Test] + public function tarball_url_builds_a_ref_encoded_codeload_archive_url(): void + { + $this->assertSame( + 'https://github.com/acme/widget/archive/v1.2.0.tar.gz', + Tiger_Module_Github::tarballUrl('acme', 'widget', 'v1.2.0') + ); + // A ref with a slash (a branch like release/1.x) is percent-encoded. + $this->assertSame( + 'https://github.com/acme/widget/archive/release%2F1.x.tar.gz', + Tiger_Module_Github::tarballUrl('acme', 'widget', 'release/1.x') + ); + } +} diff --git a/tests/Unit/Module/RegistryTest.php b/tests/Unit/Module/RegistryTest.php new file mode 100644 index 0000000..496a54c --- /dev/null +++ b/tests/Unit/Module/RegistryTest.php @@ -0,0 +1,225 @@ +cacheDir = rtrim(APPLICATION_ROOT, '/') . '/storage/cache'; + @mkdir($this->cacheDir, 0775, true); + // A consistent sponsor overlay for the whole class (Registry::sponsored() memoizes per-process). + $this->primeSponsored(['Acme_Widget' => ['priority' => 50, 'label' => 'Sponsored']]); + } + + protected function tearDown(): void + { + foreach ($this->wrote as $f) { @unlink($f); } + parent::tearDown(); + } + + private function primeIndex(array $index): void + { + $file = $this->cacheDir . '/registry-index.json'; + file_put_contents($file, json_encode($index)); + $this->wrote[] = $file; + } + + private function primeSponsored(array $listings): void + { + $file = $this->cacheDir . '/registry-sponsored.json'; + file_put_contents($file, json_encode(['listings' => $listings])); + $this->wrote[] = $file; + } + + /** A minimal two-module index: one sponsored (Acme/Widget), one not (Beta/Gadget). */ + private function primeTwoModuleIndex(): void + { + $this->primeIndex(['modules' => [ + [ + 'module' => 'Widget', + 'slug' => 'widget', + 'description' => 'A handy widget', + 'keywords' => ['ui', 'tool'], + 'vendor' => 'Acme', + 'type' => 'plugin', + 'repository' => 'https://github.com/Acme/Widget', + 'review' => ['reviewed_at' => '2026-01-01'], + ], + [ + 'module' => 'Gadget', + 'slug' => 'gadget', + 'description' => 'A shiny gadget', + 'keywords' => ['theme'], + 'vendor' => 'Beta', + 'type' => 'theme', + 'repository' => 'https://github.com/Beta/Gadget', + 'review' => ['reviewed_at' => '2026-06-01'], + ], + ]]); + } + + // ---- availability / index ---------------------------------------------- + + #[Test] + public function available_is_true_when_the_index_cache_is_fresh(): void + { + $this->primeTwoModuleIndex(); + $this->assertTrue(Tiger_Module_Registry::available()); + $this->assertIsArray(Tiger_Module_Registry::index()); + } + + // ---- search filtering --------------------------------------------------- + + #[Test] + public function search_with_no_query_returns_every_module(): void + { + $this->primeTwoModuleIndex(); + $this->assertCount(2, Tiger_Module_Registry::search('')); + } + + #[Test] + public function search_matches_across_name_slug_description_keywords_vendor_and_type(): void + { + $this->primeTwoModuleIndex(); + $this->assertCount(1, Tiger_Module_Registry::search('widget'), 'name/slug hit'); + $this->assertCount(1, Tiger_Module_Registry::search('shiny'), 'description hit'); + $this->assertCount(1, Tiger_Module_Registry::search('theme'), 'keyword/type hit — Gadget'); + $this->assertSame([], Tiger_Module_Registry::search('nonexistent-term'), 'a miss returns []'); + } + + // ---- sponsor merge + orderings ----------------------------------------- + + #[Test] + public function search_merges_the_curated_sponsor_placement(): void + { + $this->primeTwoModuleIndex(); + $rows = Tiger_Module_Registry::search('widget'); + + $this->assertSame(50, $rows[0]['priority'], 'the sponsored priority is attached'); + $this->assertTrue($rows[0]['sponsored']); + $this->assertSame('Sponsored', $rows[0]['sponsored_label']); + } + + #[Test] + public function an_unsponsored_module_gets_zero_priority_and_no_badge(): void + { + $this->primeTwoModuleIndex(); + $gadget = Tiger_Module_Registry::search('gadget')[0]; + $this->assertSame(0, $gadget['priority']); + $this->assertArrayNotHasKey('sponsored', $gadget); + } + + #[Test] + public function featured_sort_floats_the_sponsored_module_to_the_top(): void + { + $this->primeTwoModuleIndex(); + $rows = Tiger_Module_Registry::search('', 'featured'); + $this->assertSame('widget', $rows[0]['slug'], 'sponsored (priority 50) leads featured'); + } + + #[Test] + public function title_sort_orders_alphabetically(): void + { + $this->primeTwoModuleIndex(); + $rows = Tiger_Module_Registry::search('', 'title'); + $this->assertSame(['gadget', 'widget'], [$rows[0]['slug'], $rows[1]['slug']]); + } + + #[Test] + public function latest_sort_orders_by_newest_review(): void + { + $this->primeTwoModuleIndex(); + $rows = Tiger_Module_Registry::search('', 'latest'); + $this->assertSame('gadget', $rows[0]['slug'], 'Gadget was reviewed more recently'); + } + + #[Test] + public function an_unknown_sort_falls_back_to_featured(): void + { + $this->primeTwoModuleIndex(); + $rows = Tiger_Module_Registry::search('', 'bogus-sort'); + $this->assertSame('widget', $rows[0]['slug']); + } + + // ---- media resolution --------------------------------------------------- + + #[Test] + public function repo_relative_media_resolves_to_raw_urls_and_video_providers_become_embeds(): void + { + $this->primeIndex(['modules' => [[ + 'module' => 'Media', + 'slug' => 'media', + 'repository' => 'https://github.com/Acme/Media', + 'ref' => 'v2.0.0', + 'logo' => 'assets/logo.png', + 'hero' => 'https://cdn.example/hero.png', // already absolute — passed through + 'screenshots' => ['assets/a.png', 'assets/b.png'], + 'video' => ['src' => 'https://youtu.be/abc123', 'poster' => 'assets/poster.png'], + ]]]); + + $m = Tiger_Module_Registry::search('media')[0]; + $this->assertSame('https://raw.githubusercontent.com/Acme/Media/v2.0.0/assets/logo.png', $m['logo']); + $this->assertSame('https://cdn.example/hero.png', $m['hero'], 'an absolute URL is untouched'); + $this->assertSame('https://raw.githubusercontent.com/Acme/Media/v2.0.0/assets/a.png', $m['screenshots'][0]); + $this->assertSame('https://www.youtube-nocookie.com/embed/abc123', $m['video']['src']); + $this->assertSame('iframe', $m['video']['type']); + $this->assertStringStartsWith('https://raw.githubusercontent.com/Acme/Media/v2.0.0/', $m['video']['poster']); + } + + #[Test] + public function a_vimeo_video_becomes_a_player_embed_and_a_self_hosted_mp4_resolves_to_raw(): void + { + $this->primeIndex(['modules' => [ + ['module' => 'Vim', 'slug' => 'vim', 'repository' => 'https://github.com/Acme/Vim', + 'video' => 'https://vimeo.com/76543'], + ['module' => 'Mp4', 'slug' => 'mp4', 'repository' => 'https://github.com/Acme/Mp4', + 'video' => 'media/demo.mp4'], + ]]); + + $vim = Tiger_Module_Registry::search('vim')[0]; + $this->assertSame('https://player.vimeo.com/video/76543', $vim['video']['src']); + $this->assertSame('iframe', $vim['video']['type']); + + $mp4 = Tiger_Module_Registry::search('mp4')[0]; + $this->assertStringEndsWith('/media/demo.mp4', $mp4['video']['src']); + $this->assertSame('video', $mp4['video']['type']); + } + + // ---- config-overridable endpoints -------------------------------------- + + #[Test] + public function index_and_sponsor_urls_default_then_honor_a_config_override(): void + { + $this->assertSame(Tiger_Module_Registry::DEFAULT_INDEX, Tiger_Module_Registry::indexUrl()); + $this->assertSame(Tiger_Module_Registry::DEFAULT_SPONSORS, Tiger_Module_Registry::sponsoredUrl()); + + $this->setConfig(['tiger' => ['modules' => [ + 'registry' => 'https://example.test/index.json', + 'sponsors' => 'https://example.test/sponsors.json', + ]]]); + $this->assertSame('https://example.test/index.json', Tiger_Module_Registry::indexUrl()); + $this->assertSame('https://example.test/sponsors.json', Tiger_Module_Registry::sponsoredUrl()); + } +} diff --git a/tests/Unit/OpenApi/GeneratorTest.php b/tests/Unit/OpenApi/GeneratorTest.php new file mode 100644 index 0000000..dc2311c --- /dev/null +++ b/tests/Unit/OpenApi/GeneratorTest.php @@ -0,0 +1,206 @@ + true, 'label' => 'The title']], + ['text', 'note', []], + ['checkbox', 'active', []], + ['password', 'secret', []], + ['text', 'csrf', []], // must be excluded by the _csrf/csrf guard + ]; + } + } + } + + if (!class_exists('Openapifix_Service_Widget')) { + /** A fixture service. `create` carries a summary + description + @apiRequest; `plainlist` has no + * docblock (fallback summary + generic body); `_helper` and the static are skipped. */ + class Openapifix_Service_Widget extends \Tiger_Service_Service + { + /** + * Create a widget. + * + * A longer description spanning + * multiple lines. + * + * @apiRequest Openapifix_Form_Widget + */ + public function create(array $params) {} + + public function plainlist(array $params) {} + + public function _helper(array $params) {} + + public static function bulk(array $params) {} + } + } + + // Elements whose lowercased class names contain the tokens _elementSchema keys on. + if (!class_exists('Openapifix_El_Emailish')) { class Openapifix_El_Emailish extends \Zend_Form_Element_Text {} } + if (!class_exists('Openapifix_El_Numberish')) { class Openapifix_El_Numberish extends \Zend_Form_Element_Text {} } + if (!class_exists('Openapifix_El_Spinnerish')){ class Openapifix_El_Spinnerish extends \Zend_Form_Element_Text {} } +} + +namespace Tiger\Tests\Unit\OpenApi { + + use PHPUnit\Framework\Attributes\CoversClass; + use PHPUnit\Framework\Attributes\Test; + use Tiger\Tests\Support\UnitTestCase; + use Tiger_OpenApi_Generator; + + /** + * Tiger_OpenApi_Generator — the "reflect the /api surface into OpenAPI 3" generator (WEBSERVICES.md + * §9). Pure reflection, no boot: we feed it a fixture service + form and assert the emitted document + * — operations, the form→schema mapping, the shared response envelope, module tags, docblock + * summaries, and the source-scan discovery. + */ + #[CoversClass(Tiger_OpenApi_Generator::class)] + final class GeneratorTest extends UnitTestCase + { + private Tiger_OpenApi_Generator $gen; + + protected function setUp(): void + { + parent::setUp(); + $this->gen = new Tiger_OpenApi_Generator(); + } + + #[Test] + public function the_document_has_the_openapi_shell_and_the_response_envelope_component(): void + { + $doc = $this->gen->generate([]); + + $this->assertSame('3.0.3', $doc['openapi']); + $this->assertSame('Tiger API', $doc['info']['title']); + $this->assertSame([['url' => '/']], $doc['servers']); + $this->assertArrayHasKey('TigerResponse', $doc['components']['schemas']); + + $env = $doc['components']['schemas']['TigerResponse']; + $this->assertSame(['result'], $env['required']); + $this->assertSame([0, 1], $env['properties']['result']['enum']); + $this->assertSame(['success', 'error', 'alert', 'info'], $env['properties']['messages']['items']['properties']['class']['enum']); + } + + #[Test] + public function info_overrides_merge_over_the_defaults(): void + { + $gen = new Tiger_OpenApi_Generator(['title' => 'My API', 'version' => '9.9.9']); + $doc = $gen->generate([]); + $this->assertSame('My API', $doc['info']['title']); + $this->assertSame('9.9.9', $doc['info']['version']); + $this->assertNotEmpty($doc['info']['description'], 'the default description fills the gap'); + } + + #[Test] + public function a_service_becomes_operations_tagged_by_module(): void + { + $doc = $this->gen->generate(['Openapifix_Service_Widget']); + + // One path per public, own, non-underscore, instance method — static + `_helper` excluded. + $this->assertArrayHasKey('/api/openapifix/widget/create', $doc['paths']); + $this->assertArrayHasKey('/api/openapifix/widget/plainlist', $doc['paths']); + $this->assertArrayNotHasKey('/api/openapifix/widget/bulk', $doc['paths'], 'a static method is not an operation'); + $this->assertArrayNotHasKey('/api/openapifix/widget/_helper', $doc['paths']); + + $op = $doc['paths']['/api/openapifix/widget/create']['post']; + $this->assertSame('openapifix.widget.create', $op['operationId']); + $this->assertSame(['openapifix'], $op['tags']); + $this->assertSame('Create a widget.', $op['summary']); + $this->assertStringContainsString('longer description', $op['description']); + $this->assertStringContainsString('POST /api', $op['description']); + $this->assertSame('#/components/schemas/TigerResponse', + $op['responses']['200']['content']['application/json']['schema']['$ref']); + + $this->assertSame([['name' => 'openapifix']], $doc['tags']); + } + + #[Test] + public function a_method_without_a_docblock_gets_a_fallback_summary_and_a_generic_body(): void + { + $doc = $this->gen->generate(['Openapifix_Service_Widget']); + $op = $doc['paths']['/api/openapifix/widget/plainlist']['post']; + + $this->assertSame('widget · plainlist', $op['summary'], 'no docblock → service · method'); + $schema = $op['requestBody']['content']['application/x-www-form-urlencoded']['schema']; + $this->assertTrue($schema['additionalProperties'], 'no @apiRequest form → a generic object body'); + } + + #[Test] + public function the_request_body_schema_is_built_from_the_apiRequest_form(): void + { + $doc = $this->gen->generate(['Openapifix_Service_Widget']); + $schema = $doc['paths']['/api/openapifix/widget/create']['post'] + ['requestBody']['content']['application/x-www-form-urlencoded']['schema']; + + $this->assertSame('object', $schema['type']); + $props = $schema['properties']; + + $this->assertArrayNotHasKey('csrf', $props, 'a csrf field is dropped from the schema'); + $this->assertSame('string', $props['title']['type']); + $this->assertSame('The title', $props['title']['description'], 'the element label becomes the description'); + $this->assertSame('boolean', $props['active']['type'], 'a checkbox → boolean'); + $this->assertSame('string', $props['secret']['type']); + $this->assertSame('password', $props['secret']['format'], 'a password element → format:password'); + + $this->assertContains('title', $schema['required']); + $this->assertNotContains('note', $schema['required']); + } + + #[Test] + public function element_schema_maps_email_number_and_spinner_by_class_name(): void + { + $m = new \ReflectionMethod(Tiger_OpenApi_Generator::class, '_elementSchema'); + + $email = $m->invoke($this->gen, new \Openapifix_El_Emailish('e')); + $this->assertSame(['type' => 'string', 'format' => 'email'], $email); + + $number = $m->invoke($this->gen, new \Openapifix_El_Numberish('n')); + $this->assertSame(['type' => 'number'], $number); + + $spinner = $m->invoke($this->gen, new \Openapifix_El_Spinnerish('s')); + $this->assertSame(['type' => 'number'], $spinner); + } + + #[Test] + public function a_non_service_class_is_skipped(): void + { + // A real class that does NOT extend Tiger_Service_Service is ignored, not fatal. + $doc = $this->gen->generate(['Zend_Config', 'DoesNotExist_Service_Nope']); + $this->assertSame([], $doc['paths']); + $this->assertSame([], $doc['tags']); + } + + #[Test] + public function discover_scans_source_for_service_class_names(): void + { + $dir = sys_get_temp_dir() . '/tiger_oa_' . bin2hex(random_bytes(4)); + @mkdir($dir, 0775, true); + file_put_contents($dir . '/Widget.php', "gen->discover([$dir]); + sort($found); + $this->assertSame(['Sample_Service_Notes', 'Sample_Service_Widget'], $found); + + array_map('unlink', glob($dir . '/*')); + @rmdir($dir); + } + } +} diff --git a/tests/Unit/Profile/TabsTest.php b/tests/Unit/Profile/TabsTest.php new file mode 100644 index 0000000..d14f57d --- /dev/null +++ b/tests/Unit/Profile/TabsTest.php @@ -0,0 +1,96 @@ + 'basic', 'view' => 'index/_basic']); + + $tabs = Tiger_Profile_Tabs::all(Tiger_Profile_Tabs::CONTEXT_USER); + $this->assertCount(1, $tabs); + $tab = $tabs[0]; + $this->assertSame('basic', $tab['key']); + $this->assertSame('basic', $tab['label'], 'label defaults to the key'); + $this->assertSame('fa-circle', $tab['icon'], 'a default icon is filled'); + $this->assertSame(100, $tab['order'], 'a default order is filled'); + $this->assertSame('index/_basic', $tab['view']); + } + + #[Test] + public function tabs_are_ordered_by_order_then_label(): void + { + Tiger_Profile_Tabs::register(Tiger_Profile_Tabs::CONTEXT_USER, ['key' => 'z', 'label' => 'Zed', 'order' => 10]); + Tiger_Profile_Tabs::register(Tiger_Profile_Tabs::CONTEXT_USER, ['key' => 'a', 'label' => 'Alpha', 'order' => 50]); + Tiger_Profile_Tabs::register(Tiger_Profile_Tabs::CONTEXT_USER, ['key' => 'b', 'label' => 'Beta', 'order' => 10]); + + $keys = array_column(Tiger_Profile_Tabs::all(Tiger_Profile_Tabs::CONTEXT_USER), 'key'); + // order 10 first (Beta before Zed alphabetically), then order 50. + $this->assertSame(['b', 'z', 'a'], $keys); + } + + #[Test] + public function re_registering_a_key_overrides_the_previous_tab(): void + { + Tiger_Profile_Tabs::register(Tiger_Profile_Tabs::CONTEXT_USER, ['key' => 'basic', 'label' => 'Original']); + Tiger_Profile_Tabs::register(Tiger_Profile_Tabs::CONTEXT_USER, ['key' => 'basic', 'label' => 'Replaced']); + + $tabs = Tiger_Profile_Tabs::all(Tiger_Profile_Tabs::CONTEXT_USER); + $this->assertCount(1, $tabs, 'same key does not duplicate'); + $this->assertSame('Replaced', $tabs[0]['label']); + } + + #[Test] + public function contexts_are_isolated(): void + { + Tiger_Profile_Tabs::register(Tiger_Profile_Tabs::CONTEXT_USER, ['key' => 'u']); + Tiger_Profile_Tabs::register(Tiger_Profile_Tabs::CONTEXT_ORG, ['key' => 'o']); + + $this->assertSame(['u'], array_column(Tiger_Profile_Tabs::all(Tiger_Profile_Tabs::CONTEXT_USER), 'key')); + $this->assertSame(['o'], array_column(Tiger_Profile_Tabs::all(Tiger_Profile_Tabs::CONTEXT_ORG), 'key')); + } + + #[Test] + public function an_unknown_context_is_a_no_op_and_reads_empty(): void + { + Tiger_Profile_Tabs::register('nonsense', ['key' => 'x']); + $this->assertSame([], Tiger_Profile_Tabs::all('nonsense'), 'unknown context reads empty'); + // registering into it did not leak into a real context + $this->assertSame([], Tiger_Profile_Tabs::all(Tiger_Profile_Tabs::CONTEXT_USER)); + } + + #[Test] + public function a_keyless_tab_is_ignored(): void + { + Tiger_Profile_Tabs::register(Tiger_Profile_Tabs::CONTEXT_USER, ['label' => 'No Key']); + $this->assertSame([], Tiger_Profile_Tabs::all(Tiger_Profile_Tabs::CONTEXT_USER)); + } +} diff --git a/tests/Unit/Profile/TypesTest.php b/tests/Unit/Profile/TypesTest.php new file mode 100644 index 0000000..bc578b7 --- /dev/null +++ b/tests/Unit/Profile/TypesTest.php @@ -0,0 +1,80 @@ + label` + * map, with a base default and a graceful fall-through when config is absent or empty. Tests seed the + * registry config via the base helper and assert the parse, the slug lowercasing, and every fallback. + */ +#[CoversClass(Tiger_Profile_Types::class)] +final class TypesTest extends UnitTestCase +{ + #[Test] + public function the_base_defaults_apply_with_no_config(): void + { + // No Zend_Config in the registry → _cfg falls back to the DEFAULT constants. + $this->assertSame( + ['phone' => 'Phone', 'email' => 'Email', 'website' => 'Website', 'other' => 'Other'], + Tiger_Profile_Types::contact() + ); + $this->assertSame( + ['home' => 'Home', 'office' => 'Office', 'mailing' => 'Mailing'], + Tiger_Profile_Types::address() + ); + } + + #[Test] + public function a_config_list_overrides_the_defaults_and_slugs_the_value(): void + { + $this->setConfig(['tiger' => ['profile' => [ + 'contact' => ['types' => 'Mobile, Fax , Skype'], + 'address' => ['types' => 'Billing,Shipping'], + ]]]); + + $this->assertSame( + ['mobile' => 'Mobile', 'fax' => 'Fax', 'skype' => 'Skype'], + Tiger_Profile_Types::contact(), + 'items are trimmed; value = lowercased slug, label = as configured' + ); + $this->assertSame( + ['billing' => 'Billing', 'shipping' => 'Shipping'], + Tiger_Profile_Types::address() + ); + } + + #[Test] + public function an_empty_or_whitespace_config_falls_back_to_the_default(): void + { + $this->setConfig(['tiger' => ['profile' => [ + 'contact' => ['types' => ' , , '], // nothing survives the filter → default + ]]]); + + $this->assertSame( + ['phone' => 'Phone', 'email' => 'Email', 'website' => 'Website', 'other' => 'Other'], + Tiger_Profile_Types::contact() + ); + } + + #[Test] + public function a_missing_leaf_key_falls_back_to_the_default(): void + { + // tiger.profile exists but not .contact.types → _cfg returns the default string. + $this->setConfig(['tiger' => ['profile' => ['address' => ['types' => 'Home']]]]); + + $this->assertSame( + ['phone' => 'Phone', 'email' => 'Email', 'website' => 'Website', 'other' => 'Other'], + Tiger_Profile_Types::contact(), + 'a partially-configured tree still defaults the unset list' + ); + $this->assertSame(['home' => 'Home'], Tiger_Profile_Types::address()); + } +} diff --git a/tests/Unit/RecaptchaHubTest.php b/tests/Unit/RecaptchaHubTest.php new file mode 100644 index 0000000..ec6ec4f --- /dev/null +++ b/tests/Unit/RecaptchaHubTest.php @@ -0,0 +1,133 @@ +setConfig(['tiger' => []]); + $this->assertFalse(Tiger_Recaptcha::isEnabled()); + $this->assertSame('', Tiger_Recaptcha::siteKey()); + $this->assertSame('v2', Tiger_Recaptcha::version()); + $this->assertSame(0.5, Tiger_Recaptcha::minScore()); + $this->assertTrue(Tiger_Recaptcha::failOpen(), 'fail-open defaults on (availability)'); + $this->assertFalse(Tiger_Recaptcha::hideBadge()); + } + + #[Test] + public function config_accessors_reflect_the_cascade(): void + { + $this->setConfig(['tiger' => ['recaptcha' => [ + 'enabled' => 1, + 'version' => 'v3', + 'site_key' => 'SITE', + 'min_score' => 0.7, + 'fail_open' => 0, + 'hide_badge' => 1, + ]]]); + + $this->assertTrue(Tiger_Recaptcha::isEnabled()); + $this->assertSame('v3', Tiger_Recaptcha::version()); + $this->assertSame('SITE', Tiger_Recaptcha::siteKey()); + $this->assertSame(0.7, Tiger_Recaptcha::minScore()); + $this->assertFalse(Tiger_Recaptcha::failOpen()); + $this->assertTrue(Tiger_Recaptcha::hideBadge()); + } + + #[Test] + public function an_unknown_version_normalizes_to_v2(): void + { + $this->setConfig(['tiger' => ['recaptcha' => ['version' => 'v9']]]); + $this->assertSame('v2', Tiger_Recaptcha::version()); + } + + #[Test] + public function the_secret_key_is_read_plaintext_from_config(): void + { + $this->setConfig(['tiger' => ['recaptcha' => ['secret_key' => 'PLAIN-SECRET']]]); + $this->assertSame('PLAIN-SECRET', Tiger_Recaptcha::secretKey()); + } + + #[Test] + public function the_secret_key_is_decrypted_from_an_encrypted_config_value(): void + { + $key = base64_encode(str_repeat("\x11", 32)); + $this->setConfig(['tiger' => ['crypto' => ['key' => $key]]]); // register the key first… + $enc = Tiger_Crypto::encrypt('ENC-SECRET'); // …then encrypt + $this->setConfig(['tiger' => [ + 'crypto' => ['key' => $key], + 'recaptcha' => ['secret_key_enc' => $enc], + ]]); + + $this->assertSame('ENC-SECRET', Tiger_Recaptcha::secretKey()); + } + + #[Test] + public function the_secret_key_is_empty_when_nothing_is_configured(): void + { + $this->setConfig(['tiger' => ['recaptcha' => []]]); + $this->assertSame('', Tiger_Recaptcha::secretKey()); + } + + #[Test] + public function verify_short_circuits_without_http_when_secret_or_token_is_empty(): void + { + $this->setConfig(['tiger' => ['recaptcha' => []]]); // no secret + $this->assertSame(['success' => false], Tiger_Recaptcha::verify('any-token')); + + $this->setConfig(['tiger' => ['recaptcha' => ['secret_key' => 'S']]]); + $this->assertSame(['success' => false], Tiger_Recaptcha::verify(''), 'empty token => no HTTP'); + } + + #[Test] + public function the_badge_css_and_legal_notice_only_render_for_v3_with_hide_badge(): void + { + // off by default + $this->setConfig(['tiger' => ['recaptcha' => ['version' => 'v3']]]); + $this->assertSame('', Tiger_Recaptcha::badgeCss(), 'no hide_badge => nothing'); + $this->assertSame('', Tiger_Recaptcha::legalNotice()); + + // hide_badge but v2 => nothing (v2 has no floating badge) + $this->setConfig(['tiger' => ['recaptcha' => ['version' => 'v2', 'hide_badge' => 1]]]); + $this->assertSame('', Tiger_Recaptcha::badgeCss()); + + // v3 + hide_badge => both render + $this->setConfig(['tiger' => ['recaptcha' => ['version' => 'v3', 'hide_badge' => 1]]]); + $this->assertStringContainsString('grecaptcha-badge', Tiger_Recaptcha::badgeCss()); + $this->assertStringContainsString('grecaptcha-terms', Tiger_Recaptcha::legalNotice()); + } + + #[Test] + public function settings_exposes_a_has_secret_flag_but_never_the_secret(): void + { + $this->setConfig(['tiger' => ['recaptcha' => [ + 'enabled' => 1, 'version' => 'v2', 'site_key' => 'SITE', 'secret_key' => 'SUPER-SECRET', + ]]]); + $s = Tiger_Recaptcha::settings(); + + $this->assertTrue($s['has_secret']); + $this->assertSame('SITE', $s['site_key']); + $this->assertNotContains('SUPER-SECRET', $s, 'the secret is never returned'); + } +} diff --git a/tests/Unit/Service/LocationServiceTest.php b/tests/Unit/Service/LocationServiceTest.php new file mode 100644 index 0000000..7b0d1f2 --- /dev/null +++ b/tests/Unit/Service/LocationServiceTest.php @@ -0,0 +1,125 @@ +getResponse(); + } + + // ----- unconfigured: graceful empties, no network ------------------------ + + #[Test] + public function suggest_returns_empty_results_when_no_provider_is_configured(): void + { + $res = $this->response(['action' => 'suggest', 'q' => '221B Baker Street', 'country' => 'gb']); + $this->assertSame(1, $res->result); + $this->assertSame([], $res->data['results'], 'no provider => no suggestions, no fatal'); + } + + #[Test] + public function reverse_returns_a_null_place_when_unconfigured(): void + { + $res = $this->response(['action' => 'reverse', 'lat' => '51.5', 'lng' => '-0.12']); + $this->assertSame(1, $res->result); + $this->assertNull($res->data['place']); + } + + #[Test] + public function ip_returns_a_null_place_when_unconfigured(): void + { + $_SERVER['REMOTE_ADDR'] = '8.8.8.8'; + $res = $this->response(['action' => 'ip']); + $this->assertSame(1, $res->result); + $this->assertNull($res->data['place']); + } + + // ----- configured: a fake adapter proves the mapping --------------------- + + #[Test] + public function suggest_maps_configured_provider_places_into_the_envelope(): void + { + Tiger_Location::register('faketest', FakeLocationAdapter::class); + $this->setConfig(['tiger' => ['location' => ['address' => ['provider' => 'faketest']]]]); + + $res = $this->response(['action' => 'suggest', 'q' => 'Somewhere']); + $this->assertSame(1, $res->result); + $this->assertCount(1, $res->data['results']); + $this->assertSame('Testville', $res->data['results'][0]['city'], 'the Place is normalized via toArray()'); + } + + #[Test] + public function reverse_maps_a_configured_place(): void + { + Tiger_Location::register('faketest', FakeLocationAdapter::class); + $this->setConfig(['tiger' => ['location' => ['address' => ['provider' => 'faketest']]]]); + + $res = $this->response(['action' => 'reverse', 'lat' => '1.0', 'lng' => '2.0']); + $this->assertSame(1, $res->result); + $this->assertIsArray($res->data['place']); + $this->assertSame('Testville', $res->data['place']['city']); + } +} + +/** An offline Location adapter: capable of suggest/reverse, answers with a canned Place. */ +class FakeLocationAdapter implements Tiger_Location_Adapter_Interface +{ + public function __construct($config = []) {} + + public function supports(string $cap): bool + { + return in_array($cap, [self::CAP_SUGGEST, self::CAP_REVERSE], true); + } + + public function capabilities(): array + { + return [self::CAP_SUGGEST, self::CAP_REVERSE]; + } + + public function suggest(string $query, array $opts = []): array + { + return [$this->place()]; + } + + public function geocode(string $query, array $opts = []): array + { + return [$this->place()]; + } + + public function reverse(float $lat, float $lng, array $opts = []): ?Tiger_Location_Place + { + return $this->place(); + } + + public function ip(string $ip, array $opts = []): ?Tiger_Location_Place + { + return null; + } + + private function place(): Tiger_Location_Place + { + $p = new Tiger_Location_Place(); + $p->city = 'Testville'; + $p->country = 'US'; + return $p; + } +} diff --git a/tests/Unit/Service/ServiceBaseTest.php b/tests/Unit/Service/ServiceBaseTest.php new file mode 100644 index 0000000..92e806f --- /dev/null +++ b/tests/Unit/Service/ServiceBaseTest.php @@ -0,0 +1,207 @@ + ['log' => ['writer' => 'null']]])); + Tiger_Log::reset(); + } + + protected function tearDown(): void + { + Tiger_Log::reset(); + parent::tearDown(); + } + + // ---- dispatch ------------------------------------------------------ + + #[Test] + public function a_named_action_runs_and_receives_the_whole_message(): void + { + $r = (new \ServiceProbe(['action' => 'ping', 'x' => 'hello']))->getResponse(); + $this->assertSame(1, $r->result); + $this->assertSame('hello', $r->data['pong']); + } + + #[Test] + public function no_action_leaves_an_empty_untouched_response(): void + { + $r = (new \ServiceProbe([]))->getResponse(); + $this->assertSame(0, $r->result, 'a fresh ResponseObject defaults to result=0'); + $this->assertSame([], $r->messages, 'nothing was dispatched, so no message was emitted'); + } + + #[Test] + public function an_unknown_action_is_a_clean_invalid_action_error(): void + { + $r = (new \ServiceProbe(['action' => 'doesNotExist']))->getResponse(); + $this->assertSame(0, $r->result); + $this->assertSame('core.api.error.invalid_action', $r->messages[0]->message); + } + + #[Test] + public function an_underscore_prefixed_action_cannot_reach_internal_helpers(): void + { + $r = (new \ServiceProbe(['action' => '_decodeB64']))->getResponse(); + $this->assertSame('core.api.error.invalid_action', $r->messages[0]->message, 'protected helpers are unreachable by name'); + } + + #[Test] + public function a_throwing_action_becomes_a_safe_error_envelope(): void + { + // APPLICATION_ENV is 'testing' in the harness, so the real message surfaces (prod would mask it). + $r = (new \ServiceProbe(['action' => 'boom']))->getResponse(); + $this->assertSame(0, $r->result); + $this->assertSame('kaboom', $r->messages[0]->message); + } + + // ---- base64 WAF shim ---------------------------------------------- + + #[Test] + public function decode_b64_transparently_expands_suffixed_fields_before_dispatch(): void + { + $r = (new \ServiceProbe(['action' => 'ping', 'x_b64' => base64_encode('decoded!')]))->getResponse(); + $this->assertSame('decoded!', $r->data['pong'], 'the *_b64 field is decoded into its base name'); + } + + // ---- DataTables helpers ------------------------------------------- + + #[Test] + public function dt_params_applies_sane_defaults(): void + { + $p = (new \ServiceProbe([]))->dtParams([]); + $this->assertSame(0, $p['draw']); + $this->assertSame(0, $p['start']); + $this->assertSame(25, $p['length'], 'default page size'); + $this->assertSame('', $p['search']); + $this->assertSame([], $p['order']); + } + + #[Test] + public function dt_params_clamps_length_and_treats_minus_one_as_the_max(): void + { + $probe = new \ServiceProbe([]); + $this->assertSame(100, $probe->dtParams(['length' => -1])['length'], '-1 (show all) is capped at maxLength'); + $this->assertSame(100, $probe->dtParams(['length' => 9999])['length'], 'an over-large page is clamped'); + $this->assertSame(1, $probe->dtParams(['length' => 0])['length'], 'length is floored at 1'); + $this->assertSame(10, $probe->dtParams(['length' => 500], 10)['length'], 'a lower maxLength wins'); + } + + #[Test] + public function dt_params_reads_search_as_string_or_datatables_object_and_parses_order(): void + { + $probe = new \ServiceProbe([]); + $this->assertSame('tiger', $probe->dtParams(['search' => ' tiger '])['search'], 'a bare string is trimmed'); + $this->assertSame('cub', $probe->dtParams(['search' => ['value' => 'cub']])['search'], 'the DataTables {value} shape is unwrapped'); + + $order = $probe->dtParams(['order' => [ + ['column' => '2', 'dir' => 'DESC'], + ['column' => '0', 'dir' => 'weird'], // anything but desc => ASC + ['nope' => 1], // malformed => skipped + ]])['order']; + $this->assertSame([['column' => 2, 'dir' => 'DESC'], ['column' => 0, 'dir' => 'ASC']], $order); + } + + #[Test] + public function dt_params_floors_negative_draw_and_start(): void + { + $p = (new \ServiceProbe([]))->dtParams(['draw' => -5, 'start' => -10]); + $this->assertSame(0, $p['draw']); + $this->assertSame(0, $p['start']); + } + + #[Test] + public function dt_response_emits_the_datatables_envelope_inside_the_success_shape(): void + { + $r = (new \ServiceProbe([]))->dtResponse(3, 42, 7, [['id' => 1]]); + $this->assertSame(1, $r->result); + $this->assertSame(3, $r->data['draw']); + $this->assertSame(42, $r->data['recordsTotal']); + $this->assertSame(7, $r->data['recordsFiltered']); + $this->assertSame([['id' => 1]], $r->data['data']); + } + + // ---- form errors --------------------------------------------------- + + #[Test] + public function form_errors_surfaces_ordinary_field_errors(): void + { + $form = new \FakeMessagesForm(['amount' => ['isEmpty' => 'Value is required']]); + $r = (new \ServiceProbe([]))->formErrors($form); + $this->assertSame(0, $r->result); + $this->assertSame(['amount' => ['isEmpty' => 'Value is required']], $r->form); + $this->assertSame('core.api.error.form', $r->messages[0]->message); + } + + #[Test] + public function form_errors_treats_a_csrf_failure_specially(): void + { + $form = new \FakeMessagesForm(['_csrf' => ['badHash' => 'stale token']]); + $r = (new \ServiceProbe([]))->formErrors($form); + $this->assertSame(0, $r->result); + $this->assertNull($r->form, 'a CSRF failure is not surfaced as a field error'); + $this->assertSame('core.api.error.csrf', $r->messages[0]->message); + } + } +} + +namespace { + + /** Concrete service exposing the protected base helpers + a couple of dispatchable actions. */ + class ServiceProbe extends Tiger_Service_Service + { + public function ping(array $params): void { $this->_success(['pong' => $params['x'] ?? null]); } + public function boom(array $params): void { throw new RuntimeException('kaboom'); } + + public function dtParams(array $p, int $max = 100): array { return $this->_dtParams($p, $max); } + public function dtResponse(int $draw, int $total, int $filtered, array $data): object + { + $this->_dtResponse($draw, $total, $filtered, $data); + return $this->getResponse(); + } + public function formErrors(Zend_Form $form): object + { + $this->_formErrors($form); + return $this->getResponse(); + } + } + + /** A Zend_Form stand-in with a canned getMessages() payload (no elements/session needed). */ + class FakeMessagesForm extends Zend_Form + { + private array $canned; + public function __construct(array $messages) + { + $this->canned = $messages; + parent::__construct(); + } + public function getMessages($name = null, $suppressArrayNotation = false): array + { + return $this->canned; + } + } +} diff --git a/tests/Unit/Service/ValidateTest.php b/tests/Unit/Service/ValidateTest.php new file mode 100644 index 0000000..4788a36 --- /dev/null +++ b/tests/Unit/Service/ValidateTest.php @@ -0,0 +1,89 @@ +_Form_
` from the + * message, runs that element's REAL validators, and returns `{valid, message}` in a success envelope. The + * load-bearing behaviors: an unknown/non-Tiger_Form target is treated as valid (never block the UI), the + * class name is sanitized before use, and a real form's validator decides the field. A CSRF-less fixture + * form (`Vfix_Form_Sample`, defined below in the global namespace so the built class name resolves) makes + * the whole path a pure unit test — no session, no DB. + */ + #[CoversClass(Tiger_Service_Validate::class)] + final class ValidateTest extends UnitTestCase + { + private function data(array $message): array + { + return (array) (new Tiger_Service_Validate($message + ['action' => 'field']))->getResponse()->data; + } + + #[Test] + public function an_unknown_form_is_reported_valid(): void + { + $d = $this->data(['form_module' => 'nope', 'form' => 'missing', 'field' => 'x', 'value' => 'y']); + $this->assertTrue($d['valid'], 'a non-existent form never blocks the UI'); + $this->assertSame('', $d['message']); + } + + #[Test] + public function an_empty_module_or_form_is_reported_valid(): void + { + $this->assertTrue($this->data(['form_module' => '', 'form' => 'sample', 'field' => 'x'])['valid']); + $this->assertTrue($this->data(['form_module' => 'vfix', 'form' => '', 'field' => 'x'])['valid']); + } + + #[Test] + public function a_real_form_reports_an_invalid_field(): void + { + $d = $this->data(['form_module' => 'vfix', 'form' => 'sample', 'field' => 'amount', 'value' => 'not-a-number']); + $this->assertFalse($d['valid'], 'a non-numeric amount fails the Digits validator'); + $this->assertNotSame('', $d['message'], 'the first validator message is surfaced inline'); + } + + #[Test] + public function a_real_form_reports_a_valid_field(): void + { + $d = $this->data(['form_module' => 'vfix', 'form' => 'sample', 'field' => 'amount', 'value' => '4200']); + $this->assertTrue($d['valid']); + $this->assertSame('', $d['message']); + } + + #[Test] + public function dirty_form_names_are_sanitized_to_alpha_before_resolution(): void + { + // The dots/digits are stripped, so "v.fix9"/"sam9ple" still resolves to Vfix_Form_Sample. + $d = $this->data(['form_module' => 'v.fix9', 'form' => 'sam9ple', 'field' => 'amount', 'value' => 'x']); + $this->assertFalse($d['valid'], 'sanitized segments reach the real fixture form'); + } + } +} + +namespace { + + /** A CSRF-less fixture form so convenience validation runs with no session/DB. */ + class Vfix_Form_Sample extends Tiger_Form + { + protected function csrf(): bool + { + return false; + } + + protected function elements(): array + { + return [ + ['text', 'amount', [ + 'filters' => ['StringTrim'], + 'validators' => [['Digits']], + ]], + ]; + } + } +} diff --git a/tests/Unit/Validate/RecaptchaTest.php b/tests/Unit/Validate/RecaptchaTest.php new file mode 100644 index 0000000..6b7c821 --- /dev/null +++ b/tests/Unit/Validate/RecaptchaTest.php @@ -0,0 +1,102 @@ +setConfig(['tiger' => ['recaptcha' => [ + 'enabled' => 1, + 'version' => 'v2', + 'site_key' => 'PUBLIC-SITE-KEY', + // no secret_key — verify() returns success:false without reaching Google + ]]]); + } + + #[Test] + public function it_passes_through_when_recaptcha_is_disabled(): void + { + $this->setConfig(['tiger' => ['recaptcha' => ['enabled' => 0]]]); + $v = new Tiger_Validate_Recaptcha(); + $this->assertTrue($v->isValid('', []), 'feature off => always valid'); + $this->assertSame([], $v->getMessages()); + } + + #[Test] + public function it_passes_through_when_config_is_entirely_absent(): void + { + $this->setConfig(['tiger' => []]); // no recaptcha node at all + $this->assertTrue((new Tiger_Validate_Recaptcha())->isValid('', [])); + } + + #[Test] + public function an_enabled_but_tokenless_submission_fails_missing(): void + { + $this->enableKeyless(); + $v = new Tiger_Validate_Recaptcha(); + + $this->assertFalse($v->isValid('', []), 'no token anywhere => invalid'); + $this->assertArrayHasKey(Tiger_Validate_Recaptcha::MISSING, $v->getMessages()); + } + + #[Test] + public function a_token_with_no_configured_secret_fails_closed(): void + { + // Token present, but the install has no secret — Tiger_Recaptcha::verify short-circuits to + // success:false (no HTTP), so the validator fails with the "failed" message. + $this->enableKeyless(); + $v = new Tiger_Validate_Recaptcha(); + + $this->assertFalse($v->isValid('', ['g-recaptcha-response' => 'tok-from-widget'])); + $this->assertArrayHasKey(Tiger_Validate_Recaptcha::FAILED, $v->getMessages()); + } + + #[Test] + public function the_token_is_read_from_the_element_value_when_the_context_lacks_it(): void + { + $this->enableKeyless(); + $v = new Tiger_Validate_Recaptcha(); + + // context carries no g-recaptcha-response, so the element value is used as the token — which + // means we reach verify() and fail CLOSED (FAILED), rather than the tokenless MISSING path. + $this->assertFalse($v->isValid('value-token', null)); + $this->assertArrayHasKey(Tiger_Validate_Recaptcha::FAILED, $v->getMessages()); + $this->assertArrayNotHasKey(Tiger_Validate_Recaptcha::MISSING, $v->getMessages()); + } + + #[Test] + public function the_constructor_accepts_an_expected_v3_action_as_an_array_or_config(): void + { + // Constructing with an action must not error and must not change the keyless behavior below. + $fromArray = new Tiger_Validate_Recaptcha(['action' => 'login']); + $fromConfig = new Tiger_Validate_Recaptcha(new Zend_Config(['action' => 'signup'])); + $none = new Tiger_Validate_Recaptcha(null); + + $this->setConfig(['tiger' => ['recaptcha' => ['enabled' => 0]]]); + $this->assertTrue($fromArray->isValid('', [])); + $this->assertTrue($fromConfig->isValid('', [])); + $this->assertTrue($none->isValid('', [])); + } +} diff --git a/tests/Unit/View/AssetTest.php b/tests/Unit/View/AssetTest.php new file mode 100644 index 0000000..fa5d146 --- /dev/null +++ b/tests/Unit/View/AssetTest.php @@ -0,0 +1,133 @@ +asset('/_theme/app.css')` + * appends `?v=` so a deploy's changed CSS/JS is picked up without a hard refresh — zero- + * build (mtime, no manifest), query-string (no rewrite, can't 404), feature-flagged, and graceful + * (remote/empty/missing paths pass through untouched). + * + * A pure helper: it stats a file under PUBLIC_PATH and reads a config flag from the registry, so these + * unit tests point PUBLIC_PATH at a temp docroot with a real asset file and drive the config flag + * directly. The helper memoizes per request in two process-static caches — reset before each test so + * ordering never leaks a version token or the resolved flag. + */ +#[CoversClass(Tiger_View_Helper_Asset::class)] +final class AssetTest extends UnitTestCase +{ + private static string $docroot; + + public static function setUpBeforeClass(): void + { + // A throwaway docroot with a real asset to stat. PUBLIC_PATH is a process-global constant the + // helper reads as `PUBLIC_PATH . $path`; define it once, here, at the temp docroot. + self::$docroot = sys_get_temp_dir() . '/tiger-asset-test-' . getmypid(); + @mkdir(self::$docroot . '/_theme', 0777, true); + file_put_contents(self::$docroot . '/_theme/app.css', 'body{}'); + touch(self::$docroot . '/_theme/app.css', 1713900000); + if (!defined('PUBLIC_PATH')) { + define('PUBLIC_PATH', self::$docroot); + } + } + + protected function setUp(): void + { + parent::setUp(); + $this->resetStatics(); + } + + protected function tearDown(): void + { + $this->resetStatics(); + parent::tearDown(); + } + + /** Clear the helper's per-request memo + resolved-flag caches so tests don't leak into each other. */ + private function resetStatics(): void + { + foreach (['_enabled' => null, '_memo' => []] as $name => $value) { + (new ReflectionProperty(Tiger_View_Helper_Asset::class, $name))->setValue(null, $value); + } + } + + private function helper(): Tiger_View_Helper_Asset + { + return new Tiger_View_Helper_Asset(); + } + + #[Test] + public function it_appends_a_filemtime_version_token_to_a_local_asset(): void + { + // No config => default ON. app.css exists and was stamped mtime 1713900000. + $this->assertSame('/_theme/app.css?v=1713900000', $this->helper()->asset('/_theme/app.css')); + } + + #[Test] + public function an_existing_query_string_is_preserved_with_an_ampersand(): void + { + $this->assertSame('/_theme/app.css?a=1&v=1713900000', $this->helper()->asset('/_theme/app.css?a=1')); + } + + #[Test] + public function a_fragment_is_kept_last_after_the_version_token(): void + { + $this->assertSame('/_theme/app.css?v=1713900000#top', $this->helper()->asset('/_theme/app.css#top')); + } + + #[Test] + public function a_missing_file_passes_through_unversioned(): void + { + $this->assertSame('/_theme/nope.css', $this->helper()->asset('/_theme/nope.css')); + } + + #[Test] + public function a_remote_url_passes_through_untouched(): void + { + $this->assertSame('https://cdn.example/app.css', $this->helper()->asset('https://cdn.example/app.css')); + } + + #[Test] + public function a_protocol_relative_url_passes_through_untouched(): void + { + $this->assertSame('//cdn.example/app.css', $this->helper()->asset('//cdn.example/app.css')); + } + + #[Test] + public function an_empty_path_passes_through_untouched(): void + { + $this->assertSame('', $this->helper()->asset('')); + } + + #[Test] + public function the_result_is_memoized_within_a_request(): void + { + $first = $this->helper()->asset('/_theme/app.css'); + // Change the file's mtime AFTER the first resolve — the memoized token must still be served. + touch(self::$docroot . '/_theme/app.css', 1799999999); + $this->assertSame($first, $this->helper()->asset('/_theme/app.css'), 'the per-request memo wins'); + touch(self::$docroot . '/_theme/app.css', 1713900000); // restore for the other tests + } + + #[Test] + public function the_cache_bust_flag_off_makes_paths_pass_through(): void + { + $this->setConfig(['tiger' => ['assets' => ['cache_bust' => '0']]]); + $this->assertSame('/_theme/app.css', $this->helper()->asset('/_theme/app.css')); + } + + #[Test] + public function the_cache_bust_flag_explicitly_on_still_versions(): void + { + $this->setConfig(['tiger' => ['assets' => ['cache_bust' => '1']]]); + $this->assertSame('/_theme/app.css?v=1713900000', $this->helper()->asset('/_theme/app.css')); + } +} diff --git a/tests/Unit/View/CodeInjectTest.php b/tests/Unit/View/CodeInjectTest.php new file mode 100644 index 0000000..8c82f9a --- /dev/null +++ b/tests/Unit/View/CodeInjectTest.php @@ -0,0 +1,142 @@ +codeInject('head') ?>`). + * + * It reads the compiled injection manifest (Tiger_Code_Runtime, cached — no per-request DB query) and + * renders each item: css/js → a versioned /', $footer); + } + + #[Test] + public function it_emits_nothing_for_a_position_with_no_items(): void + { + $this->primeManifest(['head' => [['type' => 'html', 'html' => 'x']], 'footer' => []]); + $this->setConfig(['tiger' => ['code' => ['enabled' => '1', 'version' => (string) self::VERSION]]]); + + $this->assertSame('', $this->helper()->codeInject('footer')); + } + + #[Test] + public function a_phtml_item_is_server_rendered_through_the_cms_renderer_guarded(): void + { + // The phtml branch renders server-side via Tiger_Cms_Renderer, guarded so a bad snippet can + // never break the page. Whether the renderer succeeds or the guard swallows it, codeInject + // must return a string (never throw) — exercising the phtml case + its try/catch. + $this->primeManifest(['head' => [['type' => 'phtml', 'code' => '']]]); + $this->setConfig(['tiger' => ['code' => ['enabled' => '1', 'version' => (string) self::VERSION]]]); + + $this->assertIsString($this->helper()->codeInject('head')); + } + + #[Test] + public function an_unknown_item_type_is_skipped(): void + { + $this->primeManifest(['head' => [['type' => 'mystery', 'x' => 1], ['type' => 'html', 'html' => 'kept']]]); + $this->setConfig(['tiger' => ['code' => ['enabled' => '1', 'version' => (string) self::VERSION]]]); + + $out = $this->helper()->codeInject('head'); + $this->assertStringContainsString('kept', $out); + $this->assertStringNotContainsString('mystery', $out); + } + + #[Test] + public function a_manifest_url_is_html_escaped(): void + { + $this->primeManifest(['head' => [['type' => 'css_asset', 'url' => '/x.css?a=1&b=2']]]); + $this->setConfig(['tiger' => ['code' => ['enabled' => '1', 'version' => (string) self::VERSION]]]); + + $this->assertStringContainsString('/x.css?a=1&b=2', $this->helper()->codeInject('head')); + } + + /** Write a manifest exactly where Tiger_Code_Runtime::injectManifest() will `include` it. */ + private function primeManifest(array $manifest): void + { + if (!is_dir($this->cacheDir)) { @mkdir($this->cacheDir, 0777, true); } + file_put_contents($this->manifestFile, 'formRecaptcha()`). v2 emits the checkbox div + the shared api.js once; + * v3 emits a hidden field + the score script that refreshes a token on submit. When reCAPTCHA is off + * (or has no site key) it renders NOTHING, so a form is unencumbered in dev. + * + * All state comes from `tiger.recaptcha.*` config (Tiger_Recaptcha reads the registry), so these unit + * tests drive it directly. The "emit the api.js once" latch is a process-static — reset before each + * test so ordering can't hide the second-render suppression. + */ +#[CoversClass(Tiger_View_Helper_FormRecaptcha::class)] +final class FormRecaptchaTest extends UnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + $this->resetScriptLatch(); + } + + protected function tearDown(): void + { + $this->resetScriptLatch(); + parent::tearDown(); + } + + private function resetScriptLatch(): void + { + (new ReflectionProperty(Tiger_View_Helper_FormRecaptcha::class, '_scriptEmitted'))->setValue(null, false); + } + + private function helper(): Tiger_View_Helper_FormRecaptcha + { + return new Tiger_View_Helper_FormRecaptcha(); + } + + private function enable(string $version, string $siteKey = 'site-abc'): void + { + $this->setConfig(['tiger' => ['recaptcha' => [ + 'enabled' => '1', + 'version' => $version, + 'site_key' => $siteKey, + ]]]); + } + + #[Test] + public function it_renders_nothing_when_recaptcha_is_disabled(): void + { + $this->setConfig(['tiger' => ['recaptcha' => ['enabled' => '0', 'site_key' => 'site-abc']]]); + $this->assertSame('', $this->helper()->formRecaptcha()); + } + + #[Test] + public function it_renders_nothing_when_enabled_without_a_site_key(): void + { + $this->setConfig(['tiger' => ['recaptcha' => ['enabled' => '1', 'site_key' => '']]]); + $this->assertSame('', $this->helper()->formRecaptcha()); + } + + #[Test] + public function v2_renders_the_checkbox_widget_with_the_site_key(): void + { + $this->enable('v2'); + $html = $this->helper()->formRecaptcha(); + + $this->assertStringContainsString('class="g-recaptcha"', $html); + $this->assertStringContainsString('data-sitekey="site-abc"', $html); + $this->assertStringContainsString('api.js', $html, 'the loader script is present on first render'); + } + + #[Test] + public function v2_honors_theme_and_size_attributes(): void + { + $this->enable('v2'); + $html = $this->helper()->formRecaptcha('g-recaptcha-response', null, ['theme' => 'dark', 'size' => 'compact']); + + $this->assertStringContainsString('data-theme="dark"', $html); + $this->assertStringContainsString('data-size="compact"', $html); + } + + #[Test] + public function v2_emits_the_shared_api_script_only_once_per_render_pass(): void + { + $this->enable('v2'); + $first = $this->helper()->formRecaptcha(); + $second = $this->helper()->formRecaptcha(); + + $this->assertStringContainsString('api.js', $first); + $this->assertStringNotContainsString('api.js', $second, 'the second widget reuses the already-emitted loader'); + } + + #[Test] + public function v3_renders_the_hidden_field_and_the_score_script(): void + { + $this->enable('v3'); + $html = $this->helper()->formRecaptcha('g-recaptcha-response', null, ['action' => 'login']); + + $this->assertStringContainsString('type="hidden"', $html); + $this->assertStringContainsString('class="g-recaptcha-response"', $html); + $this->assertStringContainsString('render=site-abc', $html, 'the v3 script loads with the site key'); + $this->assertStringContainsString('"login"', $html, 'the sanitized action rides the execute() call'); + } + + #[Test] + public function v3_sanitizes_a_hostile_action_name(): void + { + $this->enable('v3'); + $html = $this->helper()->formRecaptcha('g-recaptcha-response', null, ['action' => 'log"in']); + + // Non-[A-Za-z0-9_/] chars are stripped before the action reaches the script: log"in -> loginscr. + $this->assertStringNotContainsString('log"in', $html); + $this->assertStringContainsString('"loginscr"', $html); + } +} diff --git a/tests/Unit/View/PageFieldTest.php b/tests/Unit/View/PageFieldTest.php new file mode 100644 index 0000000..80e3f23 --- /dev/null +++ b/tests/Unit/View/PageFieldTest.php @@ -0,0 +1,60 @@ +escape($this->pageField($page, 'listing.price')) ?>`. It's the render half of custom + * fields — the editor writes `page.meta.fields..`, this reads it back raw (escape at + * output). A pure delegator over Tiger_Fields::value, so it needs no DB: a plain page array with a + * `meta` JSON string (or array) is enough. + */ +#[CoversClass(Tiger_View_Helper_PageField::class)] +final class PageFieldTest extends UnitTestCase +{ + private function helper(): Tiger_View_Helper_PageField + { + return new Tiger_View_Helper_PageField(); + } + + private function page(array $fields): array + { + return ['page_id' => 'p1', 'meta' => json_encode(['fields' => $fields])]; + } + + #[Test] + public function it_reads_a_group_dot_field_value_from_a_page(): void + { + $page = $this->page(['listing' => ['price' => '9.99', 'featured' => '1']]); + $this->assertSame('9.99', $this->helper()->pageField($page, 'listing.price')); + $this->assertSame('1', $this->helper()->pageField($page, 'listing.featured')); + } + + #[Test] + public function it_returns_the_default_for_a_missing_field(): void + { + $page = $this->page(['listing' => ['price' => '9.99']]); + $this->assertSame('', $this->helper()->pageField($page, 'listing.subtitle')); + $this->assertSame('n/a', $this->helper()->pageField($page, 'listing.subtitle', 'n/a')); + } + + #[Test] + public function it_returns_the_default_for_a_missing_group(): void + { + $page = $this->page(['listing' => ['price' => '9.99']]); + $this->assertNull($this->helper()->pageField($page, 'seo.title', null)); + } + + #[Test] + public function it_reads_from_a_page_with_no_meta_at_all(): void + { + $this->assertSame('fallback', $this->helper()->pageField(['page_id' => 'p1'], 'listing.price', 'fallback')); + } +}