diff --git a/.gitignore b/.gitignore index 366d4f2..3da47c2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ .DS_Store .idea/ +# Test + runtime artifacts (never committed): the PHPUnit result cache, and the code runtime's +# compiled-bundle cache + generated public assets (Tiger_Code_Runtime writes these on compile). +/.phpunit.cache/ +/storage/ +/public/_code/ + # 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. /modules/theme-*/ diff --git a/library/Tiger/Mail.php b/library/Tiger/Mail.php index 46288e8..cb4a35e 100644 --- a/library/Tiger/Mail.php +++ b/library/Tiger/Mail.php @@ -37,6 +37,28 @@ class Tiger_Mail /** @var Zend_Config|null */ protected $_config; + /** + * A process-wide transport override. When set, it wins over the config-resolved transport for + * every send() that isn't given an explicit per-call transport — mirroring Zend_Mail's default + * transport, but honored by this wrapper (which always resolves its own, so Zend's default alone + * would never apply). Null in production (config resolution is used); the test bootstrap points it + * at a capturing Zend_Mail_Transport_File so tests never attempt real delivery. + * + * @var Zend_Mail_Transport_Abstract|null + */ + protected static $_defaultTransport = null; + + /** + * Set (or clear) the process-wide default transport used when send() gets no explicit transport. + * + * @param Zend_Mail_Transport_Abstract|null $transport the transport to use, or null to clear it + * @return void + */ + public static function setDefaultTransport($transport = null) + { + self::$_defaultTransport = $transport; + } + /** * Capture the resolved config for later transport resolution. * @@ -126,7 +148,8 @@ public function send($transport = null) $mail->setBodyText((string) $this->_text); } - $mail->send($transport ?: $this->transport()); + // Precedence: an explicit per-call transport, else the process default (tests), else config. + $mail->send($transport ?: (self::$_defaultTransport ?: $this->transport())); return $this; } diff --git a/modules/signup/services/Signup.php b/modules/signup/services/Signup.php index 50e0b8a..c35cf99 100644 --- a/modules/signup/services/Signup.php +++ b/modules/signup/services/Signup.php @@ -73,10 +73,24 @@ public function create(array $params): void (new Tiger_Model_OrgContact())->insert(['org_id' => $orgId, 'contact_id' => $contactId, 'is_primary' => 1]); (new Tiger_Model_UserContact())->insert(['user_id' => $userId, 'contact_id' => $contactId, 'is_primary' => 1]); - return ['user_id' => $userId, 'email' => strtolower(trim((string) $v['email']))]; + // The verification challenge is part of the account's unit of work — issue it INSIDE + // the transaction so it's atomic with the user. A challenge-write failure then rolls the + // whole signup back to a clean result=0, instead of committing an account and only THEN + // throwing (which reported failure on a live account the user could neither use nor + // re-create, blocked by email-uniqueness). + $token = bin2hex(random_bytes(32)); + $challengeId = (new Tiger_Model_AuthChallenge())->issue($userId, 'email_verify', $token, self::VERIFY_TTL); + + return [ + 'email' => strtolower(trim((string) $v['email'])), + 'challenge_id' => $challengeId, + 'token' => $token, + ]; }); - $this->_sendVerification($ids['user_id'], $ids['email']); + // Mail is best-effort I/O AFTER commit: the account already exists, so a mail hiccup must + // never surface as failure (_sendVerification swallows its own send errors). + $this->_sendVerification($ids['challenge_id'], $ids['token'], $ids['email']); $this->_success(['sent' => 1, 'email' => $ids['email']], 'signup.check_email'); } catch (Throwable $e) { $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); @@ -119,12 +133,9 @@ protected function _uniqueSlug($base): string return $slug; } - /** Issue an email_verify challenge and email the verification link. */ - protected function _sendVerification($userId, $email): void + /** Email the verification link for an already-issued email_verify challenge (best-effort I/O). */ + protected function _sendVerification($challengeId, $token, $email): void { - $token = bin2hex(random_bytes(32)); - $challengeId = (new Tiger_Model_AuthChallenge())->issue($userId, 'email_verify', $token, self::VERIFY_TTL); - $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; $url = $scheme . '://' . $host . '/signup/index/verify/cid/' . rawurlencode($challengeId) . '/code/' . rawurlencode($token); diff --git a/tests/COVERAGE-PLAN.md b/tests/COVERAGE-PLAN.md index 2b1f3a3..a4d02d2 100644 --- a/tests/COVERAGE-PLAN.md +++ b/tests/COVERAGE-PLAN.md @@ -512,11 +512,57 @@ configured, not a hole). Limit moved onto the Select; regression test pins it. 6. v7 UUIDs collide within a millisecond (first 12 hex = ms) — the `substr(v7,0,12)` id idiom in tests is latently flaky; use `bin2hex(random_bytes())` for unique fixture values. +### Wave 3 — the `/api` service + auth-service spine (LANDED 2026-07-24) +**Result:** 4 agents → **135 new tests** collected + verified together on one DB → the combined integration +suite is **250 tests / 845 assertions green** (was 116). Files: `Signup/SignupServiceTest` (19), +`Code/{RuntimeTest,CodeServiceTest}` + `System/{ModulesServiceTest,UpdatesServiceTest}` + `License/CheckerTest` +(35), `Access/{UserServiceTest,OrgServiceTest}` + `Cms/{PageServiceTest,MenuServiceTest,SettingsServiceTest}` +(55), `Service/AuthenticationTest` (26). Auth used Zend's `Zend_Session::$_unitTestEnabled` array-backed mode +to exercise the REAL session/lock/2FA/return-to paths under CLI (not stubs). + +**Bug fixed at collection (a test surfaced it):** `Signup_Service_Signup::create()` committed the tenant graph +in `_transaction()` and only THEN issued the `email_verify` challenge + sent mail — with the challenge +`issue()` *outside* the mail try/catch. A throw there unwound into `create()`'s catch → `result=0` **on a +fully-committed account** (unusable, and un-recreatable behind email-uniqueness). Fix: issue the challenge +INSIDE the transaction (atomic with the user — a challenge-write failure now rolls the whole signup back); +`_sendVerification` reduced to best-effort post-commit mail. (Same class of "notification failure fails a +committed write" worth auditing elsewhere.) + +**Findings (tracked, not fixed — characterized green):** +7. **Harness: base per-test `beginTransaction()` can't nest a service's own `_transaction()`** (ZF1's PDO + adapter doesn't ref-count; MySQL throws "already an active transaction"). Hit independently by 3 agents; + they worked around it (commit-and-purge / escape-and-scrub / drive the layer beneath). **Follow-up: add a + savepoint-aware/reentrant isolation mode to `IntegrationTestCase`** so service happy-paths test with clean + rollback isolation — unblocks every future service wave. +8. **`Cms_Service_Settings::save`** writes two `config` rows without a `_transaction()` — partial state + possible if the 2nd throws; diverges from the documented validate→transaction flow. (Same shape, benign + single-statement, in `Access_Service_Org::save` / `Cms_Service_Menu::save`.) +9. **`Tiger_Code_Runtime` writes real files** to `storage/cache/code` + `public/_code`; confirm both are + gitignored so a test/compile run can't leave artifacts staged. +10. DataTables `status`/`type` toolbar filters scope **both** `recordsTotal` and `recordsFiltered` (recordsTotal + is the filtered working set, not the grand total) — intentional per the model docblocks; noted for consumers. + +### Wave 3 — the `/api` service + auth-service spine (agents' brief, 2026-07-24) +**Base scaffolding landed** on `test/int-base`: `IntegrationTestCase` now ships `login()`/`loginAs()`/ +`logout()` (a real non-persistent `Zend_Auth` identity + the REAL shipped `Tiger_Acl_Acl` policy, so a +service's `_isAdmin()`/ACL gate decides against the rules that actually ship, not a fixture) — proven by +`ServiceScaffoldTest` (5 tests) dispatching the real admin-gated `Access_Service_User`. And `tests/bootstrap.php` +gained a **module-class autoloader** (`Mod_Type_Name` → `modules///Name.php`, `Mod_XController` → +`controllers/`) registered LAST — so a real `/api` service + its form/model instantiate with no `require_once` +and no ZF1 module-resource-loader boot. This is the gate that unblocks the service wave. + +Then **4 parallel agents** (own worktree + own DB `tiger_test_w3a-d`, off `test/int-base`): +- **A / signup** — `Signup_Service_Signup` (guest mass-create: happy path → user+org+membership, validation + + rollback, guest-allowed ACL). +- **B / RCE cluster** — `Tiger_Code_Runtime` (compile-gate/platform-scope), `Code_Service_Code`, + `System_Service_Modules`, `System_Service_Updates` (superadmin deny-by-default + nag-never-disable). +- **C / admin CRUD** — `Access_Service_User`/`Org`, `Cms_Service_Page`/`Menu`/`Settings` (ACL gate, datatable + envelope, validate→transaction, soft-delete/restore). +- **D / auth engine** — `Tiger_Service_Authentication` (password login, lockout, pepper, one-time challenges, + 2FA orchestration). Collect → one DB → fold into ONE PR (dodges stacked-squash pain, per Waves 1+2). + ### Next waves (unwritten — priority order per §5/§8) -- **Wave 3 — the `/api` service + auth-service spine:** needs a base enhancement first — add `login()` + - `installAcl()` helpers to `IntegrationTestCase` (identity + ACL scaffolding; the tiger-core base lacks - them). Then `Service_Authentication` (login/2FA/reset), `Signup_Service_Signup` (guest mass-create), - `System_Service_Modules` (untrusted install), `Code_Service_Code` (RCE lint gate). + `Tiger_Controller_Plugin_Authorization`. +- **Wave 3 tail:** `Tiger_Controller_Plugin_Authorization` (the unbypassable front-controller ACL gate). - **Wave 4 — satellite repos:** stand up a harness in each, then TigerShield WAF engines (`Waf`/`Blocklist`/ `RateLimit`/`Challenge` — highest-value non-core), TigerDocs, then the commerce module repos. diff --git a/tests/Integration/Access/OrgServiceTest.php b/tests/Integration/Access/OrgServiceTest.php new file mode 100644 index 0000000..c99fa09 --- /dev/null +++ b/tests/Integration/Access/OrgServiceTest.php @@ -0,0 +1,207 @@ +offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); } + parent::tearDown(); + } + + private function call(string $action, array $params = []): object + { + return (new Access_Service_Org(['action' => $action] + $params))->getResponse(); + } + + // ----- ACL gate ----------------------------------------------------------------------------- + + #[Test] + public function guest_and_plain_user_are_denied_admin_clears(): void + { + $this->login('anon', 'org-test', 'guest'); + $this->assertStringContainsString('not_allowed', json_encode($this->call('datatable')->messages), 'guest denied'); + + $this->loginAs('user'); + $this->assertSame(0, (int) $this->call('datatable')->result, 'plain user denied'); + + $this->loginAs('admin'); + $this->assertSame(1, (int) $this->call('datatable', ['draw' => 1])->result, 'admin allowed'); + } + + // ----- datatable ---------------------------------------------------------------------------- + + #[Test] + public function datatable_returns_the_envelope_and_flags(): void + { + $this->loginAs('admin'); + (new Tiger_Model_Org())->insert(['name' => 'Acme Grid Co', 'slug' => 'acme-grid', 'status' => 'active']); + + $res = $this->call('datatable', ['draw' => 3, 'start' => 0, 'length' => 25, 'search' => 'acme-grid']); + $data = $res->data; + + $this->assertSame(3, $data['draw']); + $this->assertSame(1, $data['recordsFiltered'], 'search narrows to the one match'); + $row = $data['data'][0]; + $this->assertSame('Acme Grid Co', $row['name']); + $this->assertSame('acme-grid', $row['slug']); + $this->assertTrue($row['can_edit']); + $this->assertArrayHasKey('member_count', $row); + } + + #[Test] + public function datatable_marks_the_current_org_and_blocks_deleting_it(): void + { + $orgId = (new Tiger_Model_Org())->insert(['name' => 'Current Tenant', 'slug' => 'current-tenant', 'status' => 'active']); + $this->login('actor', $orgId, 'admin'); // act IN this org + + $res = $this->call('datatable', ['draw' => 1, 'start' => 0, 'length' => 100, 'search' => 'current-tenant']); + $mine = null; + foreach ($res->data['data'] as $r) { if ($r['org_id'] === $orgId) { $mine = $r; break; } } + + $this->assertNotNull($mine); + $this->assertFalse($mine['can_delete'], 'you cannot delete the org you are acting in'); + } + + // ----- save --------------------------------------------------------------------------------- + + #[Test] + public function save_persists_a_new_org_with_a_fresh_id_and_stamps_created_by(): void + { + $this->login('org-admin', 'org-test', 'admin'); + $res = $this->call('save', ['name' => 'New Studio', 'slug' => 'New Studio!', 'status' => 'active']); + + $this->assertSame(1, (int) $res->result); + $id = $res->data['org_id']; + $this->assertNotEmpty($id); + $this->assertNotSame('org-test', $id, 'the new org gets its OWN id, not the actor tenant'); + + $row = (new Tiger_Model_Org())->findById($id); + $this->assertSame('New Studio', $row->name); + $this->assertSame('new-studio', $row->slug, 'slug is slugified (lowercased, non-alnum -> hyphen)'); + $this->assertSame('org-admin', $row->created_by, 'created_by stamped from the acting admin'); + $this->assertSame(0, (int) $row->deleted); + } + + #[Test] + public function save_derives_the_slug_from_the_name_when_slug_is_blank(): void + { + $this->loginAs('admin'); + $res = $this->call('save', ['name' => 'Derived Name Org', 'slug' => '', 'status' => 'active']); + $this->assertSame(1, (int) $res->result); + $row = (new Tiger_Model_Org())->findById($res->data['org_id']); + $this->assertSame('derived-name-org', $row->slug); + } + + #[Test] + public function an_invalid_payload_returns_form_errors_and_writes_no_row(): void + { + $this->loginAs('admin'); + $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM org'); + + $res = $this->call('save', ['name' => '', 'slug' => 'x', 'status' => 'active']); + + $this->assertSame(0, (int) $res->result, 'name is required'); + $this->assertNotNull($res->form); + $this->assertArrayHasKey('name', $res->form); + $this->assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM org'), 'no row written'); + } + + #[Test] + public function save_rejects_a_duplicate_slug(): void + { + $this->loginAs('admin'); + (new Tiger_Model_Org())->insert(['name' => 'First', 'slug' => 'dup-slug', 'status' => 'active']); + $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM org'); + + $res = $this->call('save', ['name' => 'Second', 'slug' => 'dup-slug', 'status' => 'active']); + + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('slug_taken', json_encode($res->messages)); + $this->assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM org')); + } + + #[Test] + public function save_refuses_an_org_that_is_its_own_parent(): void + { + $this->loginAs('admin'); + $id = (new Tiger_Model_Org())->insert(['name' => 'Selfy', 'slug' => 'selfy', 'status' => 'active']); + + $res = $this->call('save', ['org_id' => $id, 'name' => 'Selfy', 'slug' => 'selfy', 'parent_org_id' => $id, 'status' => 'active']); + $this->assertSame(0, (int) $res->result, 'an org cannot parent itself'); + $this->assertStringContainsString('parent_self', json_encode($res->messages)); + } + + #[Test] + public function save_updates_an_existing_org_in_place(): void + { + $this->loginAs('admin'); + $id = (new Tiger_Model_Org())->insert(['name' => 'Old Name', 'slug' => 'old-slug', 'status' => 'active']); + + $res = $this->call('save', ['org_id' => $id, 'name' => 'New Name', 'slug' => 'old-slug', 'status' => 'suspended']); + $this->assertSame(1, (int) $res->result); + $this->assertSame($id, $res->data['org_id']); + + $row = (new Tiger_Model_Org())->findById($id); + $this->assertSame('New Name', $row->name); + $this->assertSame('suspended', $row->status); + } + + // ----- delete (soft-delete) ----------------------------------------------------------------- + + #[Test] + public function delete_soft_deletes_and_reads_exclude_it(): void + { + $this->loginAs('admin'); + $model = new Tiger_Model_Org(); + $id = $model->insert(['name' => 'Doomed Org', 'slug' => 'doomed-org', 'status' => 'active']); + + $this->assertSame(1, (int) $this->call('delete', ['org_id' => $id])->result); + $this->assertSame(1, (int) $this->db->fetchOne('SELECT deleted FROM org WHERE org_id = ?', [$id])); + $this->assertNull($model->findById($id), 'a deleted org is excluded from reads'); + } + + #[Test] + public function delete_refuses_the_org_you_are_acting_in(): void + { + $model = new Tiger_Model_Org(); + $id = $model->insert(['name' => 'My Tenant', 'slug' => 'my-tenant', 'status' => 'active']); + $this->login('actor', $id, 'admin'); + + $res = $this->call('delete', ['org_id' => $id]); + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('no_self_delete', json_encode($res->messages)); + $this->assertSame(0, (int) $this->db->fetchOne('SELECT deleted FROM org WHERE org_id = ?', [$id]), 'still live'); + } +} diff --git a/tests/Integration/Access/UserServiceTest.php b/tests/Integration/Access/UserServiceTest.php new file mode 100644 index 0000000..490f3ea --- /dev/null +++ b/tests/Integration/Access/UserServiceTest.php @@ -0,0 +1,295 @@ + ['i18n' => ['locales' => 'en,es']]], true)); + } + + protected function tearDown(): void + { + // Access_Service_User::save() opens its OWN transaction (_transaction), which can't nest inside + // the harness's per-test transaction (Zend_Db/PDO has no nesting). Those tests commit the harness + // txn first (escapeTxn) and rely on this scrub — the isolated test DB's user tables are owned + // entirely by this suite (migrations seed no rows). For a still-in-transaction test the DELETEs + // run inside that txn and are undone by the base rollback, so this is harmless either way. + try { + $this->db->query('DELETE FROM user_credential'); + $this->db->query('DELETE FROM user'); + } catch (\Throwable $e) { + // ignore + } + $reg = Zend_Registry::getInstance(); + if ($reg->offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); } + parent::tearDown(); + } + + /** Dispatch the service with an action + payload and hand back the response object. */ + private function call(string $action, array $params = []): object + { + return (new Access_Service_User(['action' => $action] + $params))->getResponse(); + } + + /** Commit + leave the harness txn so the service can open its own (nested txns aren't supported). */ + private function escapeTxn(): void + { + $this->db->commit(); + } + + // ----- ACL gate (deny-by-default) ----------------------------------------------------------- + + #[Test] + public function guest_is_denied_every_action(): void + { + $this->login('anon', 'org-test', 'guest'); + foreach (['datatable', 'save', 'delete'] as $action) { + $res = $this->call($action); + $this->assertSame(0, (int) $res->result, "guest denied on {$action}"); + $this->assertStringContainsString('not_allowed', json_encode($res->messages), "ACL denial on {$action}"); + } + } + + #[Test] + public function a_plain_user_is_denied(): void + { + $this->loginAs('user'); + $res = $this->call('datatable', ['draw' => 1]); + $this->assertSame(0, (int) $res->result, 'a plain authenticated user is not an admin'); + $this->assertStringContainsString('not_allowed', json_encode($res->messages)); + } + + #[Test] + public function an_admin_clears_the_gate(): void + { + $this->loginAs('admin'); + $res = $this->call('datatable', ['draw' => 1, 'start' => 0, 'length' => 10]); + $this->assertSame(1, (int) $res->result, 'admin is allowed'); + $this->assertStringNotContainsString('not_allowed', json_encode($res->messages)); + } + + // ----- datatable ---------------------------------------------------------------------------- + + #[Test] + public function datatable_returns_the_envelope_with_permission_flags(): void + { + $this->loginAs('admin'); + (new Tiger_Model_User())->insert(['email' => 'grid1@w3ctest.com', 'status' => 'active']); + + $res = $this->call('datatable', ['draw' => 7, 'start' => 0, 'length' => 25]); + $data = $res->data; + + $this->assertSame(7, $data['draw'], 'draw echoes back'); + $this->assertArrayHasKey('recordsTotal', $data); + $this->assertArrayHasKey('recordsFiltered', $data); + $this->assertGreaterThanOrEqual(1, $data['recordsTotal']); + $this->assertNotEmpty($data['data']); + + $row = $data['data'][0]; + $this->assertArrayHasKey('can_edit', $row, 'server-computed edit flag present'); + $this->assertArrayHasKey('can_delete', $row); + $this->assertTrue($row['can_edit'], 'admin may edit'); + } + + #[Test] + public function datatable_search_narrows_records_filtered(): void + { + $this->loginAs('admin'); + $user = new Tiger_Model_User(); + $user->insert(['email' => 'needle-unique@w3ctest.com', 'status' => 'active']); + $user->insert(['email' => 'haystack-a@w3ctest.com', 'status' => 'active']); + $user->insert(['email' => 'haystack-b@w3ctest.com', 'status' => 'active']); + + $res = $this->call('datatable', ['draw' => 1, 'start' => 0, 'length' => 25, 'search' => 'needle-unique']); + $data = $res->data; + + $this->assertSame(1, $data['recordsFiltered'], 'search narrows the filtered count to the one match'); + $this->assertGreaterThanOrEqual(3, $data['recordsTotal'], 'total is the unfiltered working set'); + $this->assertSame('needle-unique@w3ctest.com', $data['data'][0]['email']); + } + + #[Test] + public function datatable_paging_limits_rows_without_shrinking_total(): void + { + $this->loginAs('admin'); + $user = new Tiger_Model_User(); + for ($i = 0; $i < 3; $i++) { $user->insert(['email' => "page{$i}@w3ctest.com", 'status' => 'active']); } + + $res = $this->call('datatable', ['draw' => 1, 'start' => 0, 'length' => 1]); + $data = $res->data; + + $this->assertCount(1, $data['data'], 'length=1 returns a single row'); + $this->assertGreaterThanOrEqual(3, $data['recordsTotal'], 'paging does not reduce recordsTotal'); + } + + #[Test] + public function datatable_marks_the_acting_user_as_self_and_blocks_self_delete(): void + { + $user = new Tiger_Model_User(); + $meId = $user->insert(['email' => 'me-self@w3ctest.com', 'status' => 'active']); + $this->login($meId, 'org-test', 'admin'); // act AS the seeded user + + $res = $this->call('datatable', ['draw' => 1, 'start' => 0, 'length' => 100, 'search' => 'me-self']); + $mine = null; + foreach ($res->data['data'] as $r) { if ($r['user_id'] === $meId) { $mine = $r; break; } } + + $this->assertNotNull($mine, 'the acting user appears in the grid'); + $this->assertTrue($mine['is_self'], 'flagged as self'); + $this->assertFalse($mine['can_delete'], 'you can never delete your own account'); + } + + // ----- save (validate -> transaction) ------------------------------------------------------- + + #[Test] + public function save_persists_a_new_user_and_stamps_created_by(): void + { + $this->login('admin-actor', 'org-test', 'admin'); + $this->escapeTxn(); + $res = $this->call('save', ['email' => 'created@w3ctest.com', 'username' => 'creado', 'status' => 'active']); + + $this->assertSame(1, (int) $res->result, 'valid payload saved'); + $id = $res->data['user_id']; + $this->assertNotEmpty($id); + + $row = (new Tiger_Model_User())->findById($id); + $this->assertNotNull($row); + $this->assertSame('created@w3ctest.com', $row->email); + $this->assertSame('creado', $row->username); + $this->assertSame('active', $row->status); + $this->assertSame(0, (int) $row->deleted, 'a fresh row is not deleted'); + $this->assertSame('admin-actor', $row->created_by, 'created_by stamped from the acting admin'); + } + + #[Test] + public function save_lowercases_and_trims_the_email(): void + { + $this->loginAs('admin'); + $this->escapeTxn(); + $res = $this->call('save', ['email' => ' MixedCase@W3Ctest.com ', 'status' => 'active']); + $this->assertSame(1, (int) $res->result); + $row = (new Tiger_Model_User())->findById($res->data['user_id']); + $this->assertSame('mixedcase@w3ctest.com', $row->email, 'email is normalized to lowercase+trimmed'); + } + + #[Test] + public function an_invalid_payload_returns_form_errors_and_writes_no_row(): void + { + $this->loginAs('admin'); + $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM user'); + + $res = $this->call('save', ['email' => 'not-an-email', 'status' => 'active']); + + $this->assertSame(0, (int) $res->result, 'invalid email is rejected'); + $this->assertNotNull($res->form, 'field errors are returned'); + $this->assertArrayHasKey('email', $res->form); + + $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM user'); + $this->assertSame($before, $after, 'nothing was inserted (rollback / pre-transaction reject)'); + } + + #[Test] + public function save_rejects_a_duplicate_email_with_a_friendly_error(): void + { + $this->loginAs('admin'); + (new Tiger_Model_User())->insert(['email' => 'taken@w3ctest.com', 'status' => 'active']); + $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM user'); + + $res = $this->call('save', ['email' => 'taken@w3ctest.com', 'status' => 'active']); + + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('email_taken', json_encode($res->messages)); + $this->assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM user'), 'no duplicate written'); + } + + #[Test] + public function save_updates_an_existing_user_in_place(): void + { + $this->loginAs('admin'); + $id = (new Tiger_Model_User())->insert(['email' => 'before@w3ctest.com', 'status' => 'active']); + $this->escapeTxn(); + + $res = $this->call('save', ['user_id' => $id, 'email' => 'after@w3ctest.com', 'status' => 'suspended']); + $this->assertSame(1, (int) $res->result); + $this->assertSame($id, $res->data['user_id'], 'same id — an update, not an insert'); + + $row = (new Tiger_Model_User())->findById($id); + $this->assertSame('after@w3ctest.com', $row->email); + $this->assertSame('suspended', $row->status); + } + + #[Test] + public function save_sets_a_password_credential_when_provided(): void + { + $this->loginAs('admin'); + $this->escapeTxn(); + $res = $this->call('save', ['email' => 'withpw@w3ctest.com', 'status' => 'active', 'new_password' => 'S3cure-P@ssw0rd-9x']); + $this->assertSame(1, (int) $res->result, 'valid password policy accepted'); + $id = $res->data['user_id']; + + $count = (int) $this->db->fetchOne( + "SELECT COUNT(*) FROM user_credential WHERE user_id = ? AND type = 'password'", + [$id] + ); + $this->assertSame(1, $count, 'a password credential row was created'); + } + + // ----- delete (soft-delete) ----------------------------------------------------------------- + + #[Test] + public function delete_soft_deletes_and_reads_exclude_it(): void + { + $this->loginAs('admin'); + $model = new Tiger_Model_User(); + $id = $model->insert(['email' => 'doomed@w3ctest.com', 'status' => 'active']); + + $res = $this->call('delete', ['user_id' => $id]); + $this->assertSame(1, (int) $res->result); + + $this->assertSame(1, (int) $this->db->fetchOne('SELECT deleted FROM user WHERE user_id = ?', [$id]), 'flag flipped'); + $this->assertNull($model->findById($id), 'findById excludes the soft-deleted row'); + } + + #[Test] + public function delete_refuses_to_delete_your_own_account(): void + { + $model = new Tiger_Model_User(); + $meId = $model->insert(['email' => 'self-del@w3ctest.com', 'status' => 'active']); + $this->login($meId, 'org-test', 'admin'); + + $res = $this->call('delete', ['user_id' => $meId]); + $this->assertSame(0, (int) $res->result, 'self-delete refused'); + $this->assertStringContainsString('no_self_delete', json_encode($res->messages)); + $this->assertSame(0, (int) $this->db->fetchOne('SELECT deleted FROM user WHERE user_id = ?', [$meId]), 'still live'); + } +} diff --git a/tests/Integration/Cms/MenuServiceTest.php b/tests/Integration/Cms/MenuServiceTest.php new file mode 100644 index 0000000..3dd581a --- /dev/null +++ b/tests/Integration/Cms/MenuServiceTest.php @@ -0,0 +1,246 @@ +db->query('DELETE FROM menu'); + } catch (\Throwable $e) { + // ignore + } + parent::tearDown(); + } + + private function call(string $action, array $params = []): object + { + return (new Cms_Service_Menu(['action' => $action] + $params))->getResponse(); + } + + private function seedItem(array $overrides): string + { + return (new Tiger_Model_Menu())->insert(array_merge([ + 'org_id' => '', + 'menu_key' => 'primary', + 'parent_id' => null, + 'sort_order' => 0, + 'label' => 'Item', + 'status' => 'published', + ], $overrides)); + } + + // ----- ACL gate ----------------------------------------------------------------------------- + + #[Test] + public function guest_and_plain_user_are_denied_admin_clears(): void + { + $this->login('anon', 'org-test', 'guest'); + $this->assertStringContainsString('not_allowed', json_encode($this->call('datatable')->messages), 'guest denied'); + + $this->loginAs('user'); + $this->assertSame(0, (int) $this->call('datatable')->result, 'plain user denied'); + + $this->loginAs('admin'); + $this->assertSame(1, (int) $this->call('datatable', ['draw' => 1])->result, 'admin allowed'); + } + + // ----- datatable ---------------------------------------------------------------------------- + + #[Test] + public function datatable_groups_by_menu_key_with_counts_and_flags(): void + { + $this->loginAs('admin'); + $this->seedItem(['menu_key' => 'primary', 'label' => 'Home', 'sort_order' => 0]); + $this->seedItem(['menu_key' => 'primary', 'label' => 'About', 'sort_order' => 1]); + $this->seedItem(['menu_key' => 'footer', 'label' => 'Legal', 'sort_order' => 0]); + + $res = $this->call('datatable', ['draw' => 2, 'start' => 0, 'length' => 25]); + $data = $res->data; + + $this->assertSame(2, $data['draw']); + $this->assertSame(2, $data['recordsTotal'], 'two distinct menus (primary, footer)'); + + $byKey = []; + foreach ($data['data'] as $r) { $byKey[$r['menu_key']] = $r; } + $this->assertSame(2, $byKey['primary']['items'], 'primary has two items'); + $this->assertSame(1, $byKey['footer']['items']); + $this->assertSame('Global', $byKey['primary']['scope'], 'org_id "" reads as Global scope'); + $this->assertTrue($byKey['primary']['can_edit']); + } + + #[Test] + public function datatable_search_narrows_by_menu_key(): void + { + $this->loginAs('admin'); + $this->seedItem(['menu_key' => 'primary', 'label' => 'Home']); + $this->seedItem(['menu_key' => 'sidebar-nav', 'label' => 'Widget']); + + $data = $this->call('datatable', ['draw' => 1, 'start' => 0, 'length' => 25, 'search' => 'sidebar'])->data; + $this->assertSame(1, $data['recordsFiltered']); + $this->assertSame('sidebar-nav', $data['data'][0]['menu_key']); + } + + // ----- save --------------------------------------------------------------------------------- + + #[Test] + public function save_inserts_an_item_stamping_created_by_and_appending_sort_order(): void + { + $this->login('menu-admin', 'org-test', 'admin'); + $this->seedItem(['menu_key' => 'primary', 'label' => 'First', 'sort_order' => 0]); + + $res = $this->call('save', ['menu_key' => 'primary', 'org_id' => '', 'label' => 'Second', 'url' => '/second']); + $this->assertSame(1, (int) $res->result); + $id = $res->data['menu_id']; + + $row = (new Tiger_Model_Menu())->find($id)->current(); + $this->assertSame('Second', $row->label); + $this->assertSame('/second', $row->url); + $this->assertSame('menu-admin', $row->created_by, 'created_by stamped'); + $this->assertSame(1, (int) $row->sort_order, 'appended after the existing item (nextSort)'); + $this->assertSame('published', $row->status); + } + + #[Test] + public function save_requires_a_menu_key(): void + { + $this->loginAs('admin'); + $res = $this->call('save', ['menu_key' => '', 'label' => 'Orphan']); + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('key_required', json_encode($res->messages)); + } + + #[Test] + public function save_requires_a_label_and_writes_no_row(): void + { + $this->loginAs('admin'); + $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'); + + $res = $this->call('save', ['menu_key' => 'primary', 'org_id' => '', 'label' => '']); + $this->assertSame(0, (int) $res->result, 'label is required'); + $this->assertNotNull($res->form); + $this->assertArrayHasKey('label', $res->form); + $this->assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu')); + } + + #[Test] + public function save_updates_an_existing_item_in_place(): void + { + $this->loginAs('admin'); + $id = $this->seedItem(['menu_key' => 'primary', 'label' => 'Old Label']); + + $res = $this->call('save', ['menu_id' => $id, 'menu_key' => 'primary', 'label' => 'New Label', 'url' => '/new']); + $this->assertSame(1, (int) $res->result); + $this->assertSame($id, $res->data['menu_id']); + + $row = (new Tiger_Model_Menu())->find($id)->current(); + $this->assertSame('New Label', $row->label); + $this->assertSame('/new', $row->url); + } + + // ----- delete (soft-delete a subtree) ------------------------------------------------------- + + #[Test] + public function delete_soft_deletes_the_item_and_its_descendants(): void + { + $this->loginAs('admin'); + $parent = $this->seedItem(['menu_key' => 'primary', 'label' => 'Parent', 'sort_order' => 0]); + $child = $this->seedItem(['menu_key' => 'primary', 'label' => 'Child', 'parent_id' => $parent, 'sort_order' => 0]); + + $res = $this->call('delete', ['menu_id' => $parent]); + $this->assertSame(1, (int) $res->result); + $this->assertSame(2, (int) $res->data['deleted'], 'the parent + its one child'); + + $this->assertSame(1, (int) $this->db->fetchOne('SELECT deleted FROM menu WHERE menu_id = ?', [$parent])); + $this->assertSame(1, (int) $this->db->fetchOne('SELECT deleted FROM menu WHERE menu_id = ?', [$child]), 'subtree removed'); + } + + // ----- deleteMenu (whole menu) -------------------------------------------------------------- + + #[Test] + public function delete_menu_soft_deletes_every_item_in_the_scope(): void + { + $this->loginAs('admin'); + $this->seedItem(['menu_key' => 'primary', 'label' => 'A']); + $this->seedItem(['menu_key' => 'primary', 'label' => 'B']); + $survivor = $this->seedItem(['menu_key' => 'footer', 'label' => 'Keep']); + + $res = $this->call('deleteMenu', ['menu_key' => 'primary', 'org_id' => '']); + $this->assertSame(1, (int) $res->result); + + $live = (int) $this->db->fetchOne("SELECT COUNT(*) FROM menu WHERE menu_key = 'primary' AND deleted = 0"); + $this->assertSame(0, $live, 'the whole primary menu is gone'); + $this->assertSame(0, (int) $this->db->fetchOne('SELECT deleted FROM menu WHERE menu_id = ?', [$survivor]), 'a different menu is untouched'); + } + + // ----- reorder (opens its own transaction) -------------------------------------------------- + + #[Test] + public function reorder_persists_new_parent_and_sort_order_within_the_scope(): void + { + $this->loginAs('admin'); + $a = $this->seedItem(['menu_key' => 'primary', 'label' => 'A', 'sort_order' => 0]); + $b = $this->seedItem(['menu_key' => 'primary', 'label' => 'B', 'sort_order' => 1]); + $this->db->commit(); // escape the harness txn — reorder opens its own + + $tree = json_encode([ + ['menu_id' => $b, 'parent_id' => null, 'sort_order' => 0], + ['menu_id' => $a, 'parent_id' => $b, 'sort_order' => 0], // A nested under B + ]); + $res = $this->call('reorder', ['menu_key' => 'primary', 'org_id' => '', 'tree' => $tree]); + + $this->assertSame(1, (int) $res->result); + $this->assertSame(2, (int) $res->data['updated'], 'both owned items updated'); + + $rowA = $this->db->fetchRow('SELECT parent_id, sort_order FROM menu WHERE menu_id = ?', [$a]); + $rowB = $this->db->fetchRow('SELECT parent_id, sort_order FROM menu WHERE menu_id = ?', [$b]); + $this->assertSame($b, $rowA['parent_id'], 'A is re-parented under B'); + $this->assertSame(0, (int) $rowA['sort_order']); + $this->assertNull($rowB['parent_id'], 'B is now top-level'); + } + + #[Test] + public function reorder_ignores_items_outside_the_named_menu(): void + { + $this->loginAs('admin'); + $inScope = $this->seedItem(['menu_key' => 'primary', 'label' => 'In', 'sort_order' => 0]); + $foreign = $this->seedItem(['menu_key' => 'other', 'label' => 'Foreign', 'sort_order' => 0]); + $this->db->commit(); + + $tree = json_encode([ + ['menu_id' => $inScope, 'parent_id' => null, 'sort_order' => 5], + ['menu_id' => $foreign, 'parent_id' => null, 'sort_order' => 9], // not in 'primary' — must be ignored + ]); + $res = $this->call('reorder', ['menu_key' => 'primary', 'org_id' => '', 'tree' => $tree]); + + $this->assertSame(1, (int) $res->data['updated'], 'only the in-scope item is touched'); + $this->assertSame(0, (int) $this->db->fetchOne('SELECT sort_order FROM menu WHERE menu_id = ?', [$foreign]), 'the foreign item is untouched'); + } +} diff --git a/tests/Integration/Cms/PageServiceTest.php b/tests/Integration/Cms/PageServiceTest.php new file mode 100644 index 0000000..cd0cc08 --- /dev/null +++ b/tests/Integration/Cms/PageServiceTest.php @@ -0,0 +1,268 @@ +db->query('DELETE FROM page_version'); + $this->db->query('DELETE FROM page_redirect'); + $this->db->query('DELETE FROM page'); + } catch (\Throwable $e) { + // ignore + } + $reg = Zend_Registry::getInstance(); + if ($reg->offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); } + parent::tearDown(); + } + + private function call(string $action, array $params = []): object + { + return (new Cms_Service_Page(['action' => $action] + $params))->getResponse(); + } + + /** Commit + leave the harness txn so Tiger_Model_Page::save can open its own. */ + private function escapeTxn(): void + { + $this->db->commit(); + } + + /** Seed a page row directly (stays in the harness txn — for read/datatable tests). */ + private function seedPage(array $overrides): string + { + return (new Tiger_Model_Page())->insert(array_merge([ + 'type' => Tiger_Model_Page::TYPE_PAGE, + 'locale' => 'en', + 'title' => 'Seed', + 'body' => '', + 'format' => Tiger_Model_Page::FORMAT_HTML, + 'status' => Tiger_Model_Page::STATUS_DRAFT, + ], $overrides)); + } + + // ----- ACL gate ----------------------------------------------------------------------------- + + #[Test] + public function guest_and_plain_user_are_denied_admin_clears(): void + { + $this->login('anon', 'org-test', 'guest'); + $this->assertStringContainsString('not_allowed', json_encode($this->call('datatable')->messages), 'guest denied'); + + $this->loginAs('user'); + $this->assertSame(0, (int) $this->call('datatable')->result, 'plain user denied'); + + $this->loginAs('admin'); + $this->assertSame(1, (int) $this->call('datatable', ['draw' => 1])->result, 'admin allowed'); + } + + // ----- datatable ---------------------------------------------------------------------------- + + #[Test] + public function datatable_returns_the_envelope_with_flags(): void + { + $this->loginAs('admin'); + $this->seedPage(['title' => 'Grid Page', 'slug' => 'grid-page', 'page_key' => 'grid-page']); + + $data = $this->call('datatable', ['draw' => 4, 'start' => 0, 'length' => 25, 'search' => 'Grid Page'])->data; + $this->assertSame(4, $data['draw']); + $this->assertSame(1, $data['recordsFiltered']); + $row = $data['data'][0]; + $this->assertSame('Grid Page', $row['title']); + $this->assertSame('/grid-page', $row['handle'], 'handle is the slug when set'); + $this->assertTrue($row['can_edit']); + $this->assertArrayHasKey('can_delete', $row); + } + + #[Test] + public function datatable_type_filter_scopes_total_and_rows(): void + { + $this->loginAs('admin'); + $this->seedPage(['type' => Tiger_Model_Page::TYPE_PAGE, 'title' => 'A Page', 'slug' => 'a-page', 'page_key' => 'a-page']); + $this->seedPage(['type' => Tiger_Model_Page::TYPE_LAYOUT, 'title' => 'A Layout', 'page_key' => 'a-layout']); + + $data = $this->call('datatable', ['draw' => 1, 'start' => 0, 'length' => 25, 'type' => 'layout'])->data; + $this->assertSame(1, $data['recordsTotal'], 'the type filter scopes the working set'); + foreach ($data['data'] as $r) { $this->assertSame('layout', $r['type']); } + } + + #[Test] + public function datatable_flags_a_future_published_page_as_scheduled(): void + { + $this->loginAs('admin'); + $future = date('Y-m-d H:i:s', time() + 86400); + $this->seedPage([ + 'title' => 'Scheduled Page', 'slug' => 'sched', 'page_key' => 'sched', + 'status' => Tiger_Model_Page::STATUS_PUBLISHED, 'published_at' => $future, + ]); + + $data = $this->call('datatable', ['draw' => 1, 'start' => 0, 'length' => 25, 'search' => 'Scheduled Page'])->data; + $this->assertTrue($data['data'][0]['scheduled'], 'published + future published_at = scheduled'); + } + + // ----- save (version-on-write) -------------------------------------------------------------- + + #[Test] + public function save_creates_a_page_org_scoped_stamped_and_versioned(): void + { + $this->login('page-admin', 'org-test', 'admin'); + $this->escapeTxn(); + + $res = $this->call('save', [ + 'title' => 'Hello World', 'slug' => 'hello-world', 'type' => 'page', + 'format' => 'html', 'status' => 'draft', 'locale' => 'en', 'body' => '

Hi

', + ]); + $this->assertSame(1, (int) $res->result); + $id = $res->data['page_id']; + + $row = (new Tiger_Model_Page())->findById($id); + $this->assertSame('Hello World', $row->title); + $this->assertSame('hello-world', $row->slug); + $this->assertSame('page-admin', $row->created_by, 'created_by stamped from the acting admin'); + $this->assertSame('org-test', $row->org_id, 'org-scoped from the acting tenant'); + + $versions = (new Tiger_Model_PageVersion())->recentForPage($id, 10); + $this->assertCount(1, $versions, 'the save snapshotted exactly one version'); + $this->assertSame('Hello World', $versions[0]->title, 'the snapshot carries the saved content'); + } + + #[Test] + public function save_derives_a_slug_and_key_from_the_title(): void + { + $this->loginAs('admin'); + $this->escapeTxn(); + $res = $this->call('save', ['title' => 'Auto Slug Page', 'type' => 'page', 'format' => 'html', 'status' => 'draft', 'locale' => 'en']); + $this->assertSame(1, (int) $res->result); + $row = (new Tiger_Model_Page())->findById($res->data['page_id']); + $this->assertSame('auto-slug-page', $row->slug, 'slug derived from title for a page'); + $this->assertSame('auto-slug-page', $row->page_key, 'page_key always set'); + } + + #[Test] + public function a_blank_title_returns_a_form_error_and_writes_no_row(): void + { + $this->loginAs('admin'); + $this->escapeTxn(); + $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'); + + $res = $this->call('save', ['title' => '', 'type' => 'page', 'format' => 'html', 'status' => 'draft', 'locale' => 'en']); + $this->assertSame(0, (int) $res->result, 'title is required'); + $this->assertNotNull($res->form); + $this->assertArrayHasKey('title', $res->form); + $this->assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'), 'nothing inserted'); + } + + #[Test] + public function save_updates_in_place_and_adds_a_version(): void + { + $this->loginAs('admin'); + $this->escapeTxn(); + $create = $this->call('save', ['title' => 'V1 Title', 'slug' => 'v-page', 'type' => 'page', 'format' => 'html', 'status' => 'draft', 'locale' => 'en', 'body' => 'one']); + $id = $create->data['page_id']; + + $update = $this->call('save', ['page_id' => $id, 'title' => 'V2 Title', 'slug' => 'v-page', 'type' => 'page', 'format' => 'html', 'status' => 'draft', 'locale' => 'en', 'body' => 'two']); + $this->assertSame(1, (int) $update->result); + $this->assertSame($id, $update->data['page_id'], 'same id — an update'); + + $row = (new Tiger_Model_Page())->findById($id); + $this->assertSame('V2 Title', $row->title); + $this->assertCount(2, (new Tiger_Model_PageVersion())->recentForPage($id, 10), 'each save snapshots a version'); + } + + #[Test] + public function changing_the_slug_leaves_a_301_redirect_behind(): void + { + $this->loginAs('admin'); + $this->escapeTxn(); + $create = $this->call('save', ['title' => 'Movable', 'slug' => 'old-path', 'type' => 'page', 'format' => 'html', 'status' => 'published', 'locale' => 'en']); + $id = $create->data['page_id']; + + $this->call('save', ['page_id' => $id, 'title' => 'Movable', 'slug' => 'new-path', 'type' => 'page', 'format' => 'html', 'status' => 'published', 'locale' => 'en']); + + $redirect = $this->db->fetchRow('SELECT from_slug, to_slug FROM page_redirect WHERE from_slug = ?', ['old-path']); + $this->assertNotFalse($redirect, 'a redirect row was recorded from the old slug'); + $this->assertSame('new-path', $redirect['to_slug'], 'it points at the new slug'); + } + + // ----- delete (soft-delete) ----------------------------------------------------------------- + + #[Test] + public function delete_soft_deletes_and_reads_exclude_it(): void + { + $this->loginAs('admin'); + $model = new Tiger_Model_Page(); + $id = $this->seedPage(['title' => 'Doomed Page', 'slug' => 'doomed', 'page_key' => 'doomed']); + + $this->assertSame(1, (int) $this->call('delete', ['page_id' => $id])->result); + $this->assertSame(1, (int) $this->db->fetchOne('SELECT deleted FROM page WHERE page_id = ?', [$id])); + $this->assertNull($model->findById($id), 'a deleted page is excluded from reads'); + } + + #[Test] + public function delete_with_no_id_is_a_clean_error(): void + { + $this->loginAs('admin'); + $res = $this->call('delete', ['page_id' => '']); + $this->assertSame(0, (int) $res->result, 'a missing id is refused, not a fatal'); + } + + // ----- restore (to a prior version) --------------------------------------------------------- + + #[Test] + public function restore_reverts_the_page_to_a_prior_version(): void + { + $this->loginAs('admin'); + $this->escapeTxn(); + $create = $this->call('save', ['title' => 'Original', 'slug' => 'restorable', 'type' => 'page', 'format' => 'html', 'status' => 'draft', 'locale' => 'en', 'body' => 'first body']); + $id = $create->data['page_id']; // version 1 = "Original" + $this->call('save', ['page_id' => $id, 'title' => 'Edited', 'slug' => 'restorable', 'type' => 'page', 'format' => 'html', 'status' => 'draft', 'locale' => 'en', 'body' => 'second body']); // version 2 + + $res = $this->call('restore', ['page_id' => $id, 'version' => 1]); + $this->assertSame(1, (int) $res->result); + + $row = (new Tiger_Model_Page())->findById($id); + $this->assertSame('Original', $row->title, 'the page reverted to version 1'); + $this->assertSame('first body', $row->body); + $this->assertCount(3, (new Tiger_Model_PageVersion())->recentForPage($id, 10), 'the restore itself snapshots a new version'); + } + + #[Test] + public function restore_with_a_bad_version_is_a_clean_error(): void + { + $this->loginAs('admin'); + $res = $this->call('restore', ['page_id' => 'whatever', 'version' => 0]); + $this->assertSame(0, (int) $res->result, 'version < 1 is refused up front'); + } +} diff --git a/tests/Integration/Cms/SettingsServiceTest.php b/tests/Integration/Cms/SettingsServiceTest.php new file mode 100644 index 0000000..9dbe21d --- /dev/null +++ b/tests/Integration/Cms/SettingsServiceTest.php @@ -0,0 +1,122 @@ +offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); } + parent::tearDown(); + } + + private function call(string $action, array $params = []): object + { + return (new Cms_Service_Settings(['action' => $action] + $params))->getResponse(); + } + + private function globalConfig(string $key): ?string + { + return (new Tiger_Model_Config())->get(Tiger_Model_Config::SCOPE_GLOBAL, '', $key); + } + + // ----- ACL gate ----------------------------------------------------------------------------- + + #[Test] + public function guest_and_plain_user_are_denied(): void + { + $this->login('anon', 'org-test', 'guest'); + $res = $this->call('save', ['site_name' => 'X']); + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('not_allowed', json_encode($res->messages), 'guest denied'); + + $this->loginAs('user'); + $res = $this->call('save', ['site_name' => 'X']); + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('not_allowed', json_encode($res->messages), 'plain user denied'); + + // Nothing leaked into config from a denied call. + $this->assertNull($this->globalConfig('tiger.site.name')); + } + + // ----- save writes to the config tier ------------------------------------------------------- + + #[Test] + public function save_writes_site_name_and_home_page_to_global_config(): void + { + $this->loginAs('admin'); + $res = $this->call('save', ['site_name' => 'My Tiger Site', 'home_page' => '']); + + $this->assertSame(1, (int) $res->result); + $this->assertSame('My Tiger Site', $this->globalConfig('tiger.site.name'), 'site name lands in the config tier'); + $this->assertSame('', $this->globalConfig('tiger.site.home_page'), 'home page (empty = built-in landing) is written too'); + } + + #[Test] + public function save_is_an_upsert_it_updates_the_same_key_in_place(): void + { + $this->loginAs('admin'); + $this->call('save', ['site_name' => 'First Name', 'home_page' => '']); + $this->call('save', ['site_name' => 'Second Name', 'home_page' => '']); + + $this->assertSame('Second Name', $this->globalConfig('tiger.site.name'), 'last write wins'); + $count = (int) $this->db->fetchOne( + "SELECT COUNT(*) FROM config WHERE scope = 'global' AND scope_id = '' AND config_key = 'tiger.site.name' AND deleted = 0" + ); + $this->assertSame(1, $count, 'upsert — a single config row, not a duplicate per save'); + } + + #[Test] + public function save_trims_the_site_name(): void + { + $this->loginAs('admin'); + $this->call('save', ['site_name' => ' Spaced Site ', 'home_page' => '']); + $this->assertSame('Spaced Site', $this->globalConfig('tiger.site.name')); + } + + // ----- validation --------------------------------------------------------------------------- + + #[Test] + public function a_blank_site_name_returns_a_form_error_and_writes_no_config(): void + { + $this->loginAs('admin'); + $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM config'); + + $res = $this->call('save', ['site_name' => '', 'home_page' => '']); + + $this->assertSame(0, (int) $res->result, 'site_name is required'); + $this->assertNotNull($res->form); + $this->assertArrayHasKey('site_name', $res->form); + $this->assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM config'), 'no config written'); + } +} diff --git a/tests/Integration/Code/CodeServiceTest.php b/tests/Integration/Code/CodeServiceTest.php new file mode 100644 index 0000000..23a5858 --- /dev/null +++ b/tests/Integration/Code/CodeServiceTest.php @@ -0,0 +1,179 @@ +messages ?? []); + } + + // ---- the crown jewel: ACL deny-by-default, superadmin-gated ----------------------------------- + + #[Test] + public function the_shipped_acl_gates_the_code_service_to_superadmin_and_up(): void + { + $this->loginAs('superadmin'); + $acl = Zend_Registry::get('Zend_Acl'); + + $this->assertTrue($acl->has('Code_Service_Code'), 'the code module acl.ini resource loaded'); + $this->assertTrue($acl->isAllowed('superadmin', 'Code_Service_Code'), 'superadmin holds code.execute'); + $this->assertTrue($acl->isAllowed('developer', 'Code_Service_Code'), 'the god developer role inherits it'); + $this->assertFalse($acl->isAllowed('admin', 'Code_Service_Code'), 'a PLAIN admin is denied — this is NOT an admin surface'); + $this->assertFalse($acl->isAllowed('manager', 'Code_Service_Code'), 'a manager is denied'); + $this->assertFalse($acl->isAllowed('guest', 'Code_Service_Code'), 'a guest is denied'); + } + + #[Test] + public function a_guest_is_denied_dispatching_the_code_service(): void + { + $this->login('anon', 'o-1', 'guest'); + $res = (new Code_Service_Code(['action' => 'datatable']))->getResponse(); + + $this->assertSame(0, (int) $res->result, 'guest denied'); + $this->assertStringContainsString('not_allowed', $this->messages($res), 'the ACL denial fired'); + } + + #[Test] + public function a_plain_admin_is_denied_dispatching_the_code_service(): void + { + // The key distinction from every other admin service: authoring PHP is superadmin+, so an + // admin who can manage users/content still cannot touch the Code Area. + $this->loginAs('admin'); + $res = (new Code_Service_Code(['action' => 'datatable']))->getResponse(); + + $this->assertSame(0, (int) $res->result, 'a plain admin is denied the code service'); + $this->assertStringContainsString('not_allowed', $this->messages($res), 'the ACL denial fired for admin'); + } + + #[Test] + public function a_superadmin_clears_the_gate(): void + { + $this->loginAs('superadmin'); + $res = (new Code_Service_Code(['action' => 'datatable', 'draw' => 1, 'start' => 0, 'length' => 10]))->getResponse(); + + $this->assertStringNotContainsString('not_allowed', $this->messages($res), 'superadmin cleared the ACL gate'); + $this->assertSame(1, (int) $res->result, 'the datatable action ran'); + } + + #[Test] + public function the_developer_god_role_clears_the_gate(): void + { + $this->loginAs('developer'); + $res = (new Code_Service_Code(['action' => 'datatable', 'draw' => 1, 'start' => 0, 'length' => 10]))->getResponse(); + + $this->assertStringNotContainsString('not_allowed', $this->messages($res), 'developer cleared the ACL gate'); + $this->assertSame(1, (int) $res->result); + } + + // ---- the safety rails ------------------------------------------------------------------------ + + #[Test] + public function save_refuses_syntactically_invalid_php_before_storing(): void + { + $this->loginAs('superadmin'); + $res = (new Code_Service_Code([ + 'action' => 'save', + 'name' => 'Broken helper', + 'language' => 'php', + 'code' => 'function broken( {', // form-valid (name present) but a parse error + 'active' => '1', + 'priority' => '100', + ]))->getResponse(); + + $this->assertSame(0, (int) $res->result, 'a parse error is refused'); + $this->assertStringContainsString('Not saved', $this->messages($res), 'the lint rejection message, not a form error'); + + // Nothing was stored. + $this->assertNull((new Tiger_Model_Code())->fetchRow(['name = ?' => 'Broken helper']), 'no row was written'); + } + + // NOTE: the save()/restore() happy paths cannot be exercised through this harness — the base's + // per-test isolation transaction collides with Tiger_Model_Code::save()'s OWN beginTransaction + // (nested begins throw "already an active transaction"). That's a harness×product interaction, not + // a product bug (a real /api request has no outer transaction). See WAVE3-FINDINGS-rce.md. The + // lint-reject path above IS the security-relevant assertion for save; the compile/rebuild happy + // path is covered directly in RuntimeTest. + + #[Test] + public function toggling_an_unknown_local_id_is_a_clean_error_not_a_crash(): void + { + $this->loginAs('superadmin'); + $res = (new Code_Service_Code(['action' => 'activate', 'code_id' => 'no-such-id']))->getResponse(); + + $this->assertSame(0, (int) $res->result, 'a missing snippet fails cleanly'); + $this->assertStringNotContainsString('not_allowed', $this->messages($res), 'it failed on the lookup, not the ACL'); + } + + #[Test] + public function module_source_for_an_unknown_key_is_a_clean_error(): void + { + // Module snippets are read-only and read LIVE from a file; an unresolvable key errors, never + // touches the DB. (No `code` module snippet is guaranteed present in the harness, so we assert + // the negative path — the one that doesn't depend on a fixture snippet existing.) + $this->loginAs('superadmin'); + $res = (new Code_Service_Code(['action' => 'moduleSource', 'code_id' => 'module:does/not-exist']))->getResponse(); + + $this->assertSame(0, (int) $res->result, 'an unknown module snippet key errors cleanly'); + } + + #[Test] + public function delete_soft_deletes_a_local_snippet_and_rebuilds(): void + { + $this->loginAs('superadmin'); + $id = (new Tiger_Model_Code())->insert([ + 'org_id' => '', + 'name' => 'Doomed', + 'language' => Tiger_Model_Code::LANG_PHP, + 'code' => "if (!function_exists('tiger_svc_doomed')) { function tiger_svc_doomed() {} }", + 'run_location' => Tiger_Model_Code::LOC_GLOBAL, + 'active' => 0, + 'status' => Tiger_Model_Code::STATUS_DRAFT, + ]); + + $res = (new Code_Service_Code(['action' => 'delete', 'code_id' => $id]))->getResponse(); + + $this->assertSame(1, (int) $res->result, 'delete succeeds'); + $this->assertNull((new Tiger_Model_Code())->findById($id), 'the row is hidden by the soft-delete finder'); + } +} diff --git a/tests/Integration/Code/RuntimeTest.php b/tests/Integration/Code/RuntimeTest.php new file mode 100644 index 0000000..9c534d0 --- /dev/null +++ b/tests/Integration/Code/RuntimeTest.php @@ -0,0 +1,141 @@ +cacheDir = APPLICATION_ROOT . '/storage/cache/code'; + } + + protected function tearDown(): void + { + // Remove only the bundles/manifests THIS test wrote (scoped to our isolated location). + foreach (glob($this->cacheDir . '/' . self::LOC . '.*.php') ?: [] as $f) { @unlink($f); } + foreach (glob($this->cacheDir . '/inject.' . self::LOC . '.*.php') ?: [] as $f) { @unlink($f); } + @unlink($this->cacheDir . '/DISABLED'); + parent::tearDown(); + } + + /** Insert an active PHP snippet row directly (bypassing the service's lint) for compile() to pick up. */ + private function insertPhp(string $name, string $code, string $orgId = '', int $priority = 100): string + { + return (new Tiger_Model_Code())->insert([ + 'org_id' => $orgId, + 'name' => $name, + 'language' => Tiger_Model_Code::LANG_PHP, + 'code' => $code, + 'run_location' => self::LOC, + 'priority' => $priority, + 'active' => 1, + 'status' => Tiger_Model_Code::STATUS_ACTIVE, + ]); + } + + #[Test] + public function a_valid_active_set_compiles_and_is_promoted(): void + { + $this->insertPhp('ok', "if (!function_exists('tiger_rce_ok')) { function tiger_rce_ok() { return 42; } }"); + + $file = Tiger_Code_Runtime::compile(self::LOC, 1); + + $this->assertFileExists($file, 'a valid bundle is written + promoted (atomic rename)'); + $this->assertStringContainsString('tiger_rce_ok', file_get_contents($file), 'the active snippet is in the bundle'); + } + + #[Test] + public function a_syntactically_broken_snippet_is_never_promoted_and_last_good_keeps_serving(): void + { + // v1: a valid, promoted bundle (the "last good"). + $this->insertPhp('good', "if (!function_exists('tiger_rce_good')) { function tiger_rce_good() { return 1; } }"); + $v1 = Tiger_Code_Runtime::compile(self::LOC, 1); + $this->assertFileExists($v1); + + // Now the active set contains a parse error. Compiling v2 must THROW and promote nothing. + $this->insertPhp('broken', 'function tiger_rce_broken( {'); // deliberate syntax error + + try { + Tiger_Code_Runtime::compile(self::LOC, 2); + $this->fail('a broken bundle must not compile'); + } catch (RuntimeException $e) { + $this->assertNotSame('', $e->getMessage(), 'the compiler surfaces the php -l error'); + } + + $this->assertFileDoesNotExist( + $this->cacheDir . '/' . self::LOC . '.2.php', + 'the invalid v2 bundle was never promoted' + ); + $this->assertFileExists($v1, 'the last-good v1 bundle still serves'); + } + + #[Test] + public function a_redeclare_conflicting_active_set_is_rejected_by_the_whole_bundle_lint(): void + { + // Two UNGUARDED definitions of the same function — each lints fine alone, but the assembled + // bundle is a "Cannot redeclare" fatal that only the whole-file php -l can see (CODE.md §4). + $this->insertPhp('dup-a', 'function tiger_rce_dup() { return 1; }', '', 10); + $this->insertPhp('dup-b', 'function tiger_rce_dup() { return 2; }', '', 20); + + $this->expectException(RuntimeException::class); + Tiger_Code_Runtime::compile(self::LOC, 1); + } + + #[Test] + public function server_php_compiles_only_from_platform_scope_rows_never_a_tenant_row(): void + { + // A platform row (org_id='') and a tenant row (org_id='org-x'), both active, same location. + $this->insertPhp('platform', "if (!function_exists('tiger_platform_fn')) { function tiger_platform_fn() {} }", ''); + $this->insertPhp('tenant', "if (!function_exists('tiger_tenant_fn')) { function tiger_tenant_fn() {} }", 'org-x'); + + $bundle = file_get_contents(Tiger_Code_Runtime::compile(self::LOC, 1)); + + $this->assertStringContainsString('tiger_platform_fn', $bundle, 'the platform-scope snippet compiles in'); + $this->assertStringNotContainsString( + 'tiger_tenant_fn', + $bundle, + 'a tenant-scoped row NEVER enters the server bundle — the RCE boundary is in compile(), not the UI' + ); + } + + #[Test] + public function the_kill_switch_disabled_file_turns_execution_off(): void + { + // Default (no DISABLED file, no config node) is enabled. + @unlink($this->cacheDir . '/DISABLED'); + $this->assertTrue(Tiger_Code_Runtime::enabled(), 'enabled by default'); + + // The fastest recovery of all: a DISABLED file in the cache dir hard-stops execution. + 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 kill-switch file disables execution'); + } +} diff --git a/tests/Integration/License/CheckerTest.php b/tests/Integration/License/CheckerTest.php new file mode 100644 index 0000000..9602b72 --- /dev/null +++ b/tests/Integration/License/CheckerTest.php @@ -0,0 +1,167 @@ + ['model' => 'licensed', 'authority' => 'https://store.example/authority', 'vendor' => 'acme/TigerVendor'], + ]; + + protected function setUp(): void + { + parent::setUp(); + Tiger_License_Checker::setStore($this->memoryStore()); + } + + protected function tearDown(): void + { + Tiger_License_Checker::_reset(); // drop injected store + transport + parent::tearDown(); + } + + /** A tiny in-memory Tiger_License_Store so the checker needs no DB. */ + private function memoryStore(): Tiger_License_Store + { + return new class implements Tiger_License_Store { + private array $rows = []; + public function get(string $slug): ?array { return $this->rows[$slug] ?? null; } + public function put(string $slug, array $record): void { $this->rows[$slug] = $record; } + public function forget(string $slug): void { unset($this->rows[$slug]); } + }; + } + + /** Make the authority reply with a fixed decoded array (unsigned/dev path). */ + private function transportReturns(?array $reply): void + { + Tiger_License_Checker::setTransport(static fn(string $authority, array $payload): ?array => $reply); + } + + // ---- unlicensed: nothing to gate ------------------------------------------------------------- + + #[Test] + public function an_unlicensed_module_is_never_gated(): void + { + // No stored license, and a free manifest → not licensed, never blocked. + $verdict = Tiger_License_Checker::verify(self::SLUG); + $this->assertSame(Tiger_License_Checker::UNLICENSED, $verdict['state']); + $this->assertTrue($verdict['can_update'], 'unlicensed always updates'); + + $gate = Tiger_License_Checker::gate(['pricing' => ['model' => 'free']], self::SLUG); + $this->assertFalse($gate['licensed']); + $this->assertFalse($gate['blocked'], 'a free module is never blocked'); + } + + // ---- fail-open: unreachable / forged authority is UNKNOWN, never lapsed ----------------------- + + #[Test] + public function an_unreachable_authority_fails_open_as_unknown(): void + { + Tiger_License_Checker::remember(self::SLUG, [ + 'key' => 'LIC-123', + 'authority' => 'https://store.example/authority', + 'vendor' => 'acme/TigerVendor', + ]); + $this->transportReturns(null); // couldn't reach home + + $verdict = Tiger_License_Checker::verify(self::SLUG); + $this->assertSame(Tiger_License_Checker::UNKNOWN, $verdict['state'], 'unreachable ≠ lapsed'); + $this->assertTrue($verdict['can_update'], 'fail-OPEN: an outage never blocks an update'); + + $gate = Tiger_License_Checker::gate(self::LICENSED_MANIFEST, self::SLUG); + $this->assertTrue($gate['licensed']); + $this->assertFalse($gate['blocked'], 'a licensed module with an unreachable authority still updates'); + } + + #[Test] + public function a_forged_signed_reply_is_untrusted_and_fails_open_not_lapsed(): void + { + // A public key is pinned, but the reply's signature doesn't verify → treated as unreachable + // (assume-current), so a forgery can nag NOBODY. + Tiger_License_Checker::remember(self::SLUG, [ + 'key' => 'LIC-123', + 'authority' => 'https://store.example/authority', + 'vendor' => 'acme/TigerVendor', + 'public_key' => str_repeat('a', 64), // a pinned (dummy) key + ]); + $this->transportReturns(['payload' => '{"valid":false}', 'signature' => 'not-a-real-signature']); + + $verdict = Tiger_License_Checker::verify(self::SLUG); + $this->assertSame(Tiger_License_Checker::UNKNOWN, $verdict['state'], 'an unverifiable reply is untrusted, not lapsed'); + $this->assertTrue($verdict['can_update'], 'a forged "lapsed" cannot withhold an update'); + } + + // ---- reached-home verdicts ------------------------------------------------------------------- + + #[Test] + public function a_reached_home_valid_verdict_permits_updates(): void + { + Tiger_License_Checker::remember(self::SLUG, [ + 'key' => 'LIC-123', + 'authority' => 'https://store.example/authority', + 'vendor' => 'acme/TigerVendor', + ]); // no pinned key → unsigned dev reply is trusted verbatim + $this->transportReturns(['valid' => true, 'ttl' => 3600, 'latest_version' => '2.0.0']); + + $verdict = Tiger_License_Checker::verify(self::SLUG); + $this->assertSame(Tiger_License_Checker::VALID, $verdict['state']); + $this->assertTrue($verdict['can_update']); + $this->assertSame('2.0.0', $verdict['latest_version']); + + $this->assertFalse(Tiger_License_Checker::gate(self::LICENSED_MANIFEST, self::SLUG)['blocked'], 'valid is never blocked'); + } + + #[Test] + public function only_a_definitive_lapsed_verdict_withholds_an_update(): void + { + Tiger_License_Checker::remember(self::SLUG, [ + 'key' => 'LIC-123', + 'authority' => 'https://store.example/authority', + 'vendor' => 'acme/TigerVendor', + ]); + $this->transportReturns(['valid' => false, 'ttl' => 3600]); // reached home, NOT entitled + + $verdict = Tiger_License_Checker::verify(self::SLUG); + $this->assertSame(Tiger_License_Checker::LAPSED, $verdict['state']); + $this->assertFalse($verdict['can_update'], 'a definitive lapse is the ONE state that blocks an update'); + + $gate = Tiger_License_Checker::gate(self::LICENSED_MANIFEST, self::SLUG); + $this->assertTrue($gate['licensed']); + $this->assertTrue($gate['blocked'], 'lapsed → the update is withheld (nag) — the module still runs (never disabled)'); + } + + #[Test] + public function can_update_mirrors_the_verdict(): void + { + Tiger_License_Checker::remember(self::SLUG, [ + 'key' => 'LIC-123', + 'authority' => 'https://store.example/authority', + 'vendor' => 'acme/TigerVendor', + ]); + + $this->transportReturns(['valid' => false]); + $this->assertFalse(Tiger_License_Checker::canUpdate(self::SLUG), 'lapsed → cannot update'); + } +} diff --git a/tests/Integration/Service/AuthenticationTest.php b/tests/Integration/Service/AuthenticationTest.php new file mode 100644 index 0000000..bf82365 --- /dev/null +++ b/tests/Integration/Service/AuthenticationTest.php @@ -0,0 +1,555 @@ +priorUnitTestMode = Zend_Session::$_unitTestEnabled; + Zend_Session::$_unitTestEnabled = true; + $_SESSION = []; + // Use the production-shaped session storage so getIdentity()/write() run the real path. + Zend_Auth::getInstance()->setStorage(new Zend_Auth_Storage_Session()); + Zend_Auth::getInstance()->clearIdentity(); + + $this->useCrypto(self::PEPPER); + $this->auth = new Tiger_Service_Authentication(); + } + + protected function tearDown(): void + { + Zend_Auth::getInstance()->clearIdentity(); + $_SESSION = []; + Zend_Session::$_unitTestEnabled = $this->priorUnitTestMode; + parent::tearDown(); + } + + /** Seed the process-global crypto key + pepper the models read from the registry (test secrets only). */ + private function useCrypto(?string $pepper): void + { + $tiger = ['crypto' => ['key' => self::KEY]]; + if ($pepper !== null) { + $tiger['security'] = ['pepper' => $pepper]; + } + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => $tiger], true)); + } + + /** A unique-ish email for a fresh fixture user. */ + private function email(): string + { + return 'auth-' . bin2hex(random_bytes(8)) . '@example.test'; + } + + /** Create an ACTIVE user with a password credential; returns [user_id, email]. */ + private function makeUserWithPassword(string $password, string $status = 'active'): array + { + $email = $this->email(); + $uid = (new Tiger_Model_User())->insert(['email' => $email, 'status' => $status]); + (new Tiger_Model_UserCredential())->setPassword($uid, $password); + return [$uid, $email]; + } + + /** Create an org and an active membership for the user with the given role; returns org_id. */ + private function makeOrgMembership(string $userId, string $role): string + { + $orgId = (new Tiger_Model_Org())->insert([ + 'name' => 'Org ' . substr(Tiger_Uuid::v7(), 0, 8), + 'slug' => 'org-' . bin2hex(random_bytes(6)), + ]); + (new Tiger_Model_OrgUser())->insert([ + 'org_id' => $orgId, 'user_id' => $userId, 'role' => $role, 'status' => 'active', + ]); + return $orgId; + } + + /** Count login-audit rows for a user with a given result. */ + private function auditCount(?string $userId, string $result, ?string $identifier = null): int + { + $sel = $this->db->select()->from('login', ['c' => 'COUNT(*)'])->where('result = ?', $result); + if ($userId !== null) { $sel->where('user_id = ?', $userId); } + if ($identifier !== null) { $sel->where('identifier = ?', $identifier); } + return (int) $this->db->fetchOne($sel); + } + + // ----- password login ---------------------------------------------------- + + #[Test] + public function a_correct_password_issues_an_identity_stamps_the_actor_and_audits_success(): void + { + [$uid, $email] = $this->makeUserWithPassword('correct horse battery'); + + $identity = $this->auth->login($email, 'correct horse battery'); + + $this->assertIsObject($identity, 'a good login returns the identity object'); + $this->assertSame($uid, $identity->user_id); + $this->assertSame($email, $identity->email); + $this->assertTrue($this->auth->isAuthenticated(), 'the session now carries an identity'); + $this->assertEquals($identity, $this->auth->getIdentity(), 'getIdentity() returns the established identity'); + $this->assertSame($uid, Tiger_Model_Table::actor(), 'the request actor is stamped to the signed-in user'); + $this->assertSame(1, $this->auditCount($uid, Tiger_Model_Login::RESULT_SUCCESS), 'a success audit row is written'); + } + + #[Test] + public function login_resolves_the_role_from_the_org_membership(): void + { + [$uid, $email] = $this->makeUserWithPassword('battery staple horse'); + $orgId = $this->makeOrgMembership($uid, 'manager'); + + $identity = $this->auth->login($email, 'battery staple horse'); + + $this->assertIsObject($identity); + $this->assertSame($orgId, $identity->org_id, 'the primary active org is resolved'); + $this->assertSame('manager', $identity->role, 'the role comes from the org_user membership, not the user'); + $this->assertNotNull($identity->org_name, 'the org name is resolved onto the identity'); + } + + #[Test] + public function a_user_with_no_membership_gets_the_base_authenticated_role_and_no_org(): void + { + [, $email] = $this->makeUserWithPassword('lonely password 1'); + + $identity = $this->auth->login($email, 'lonely password 1'); + + $this->assertIsObject($identity); + $this->assertNull($identity->org_id, 'no membership => no active org'); + $this->assertSame(Tiger_Service_Authentication::ROLE_AUTHENTICATED, $identity->role, 'falls back to the base role'); + } + + #[Test] + public function a_wrong_password_fails_with_no_session_and_an_audited_failure(): void + { + [$uid, $email] = $this->makeUserWithPassword('the right one 99'); + + $result = $this->auth->login($email, 'the WRONG one 99'); + + $this->assertFalse($result, 'a wrong password strictly returns false'); + $this->assertFalse($this->auth->isAuthenticated(), 'no session is established'); + $this->assertNull($this->auth->getIdentity()); + $this->assertSame(1, $this->auditCount($uid, Tiger_Model_Login::RESULT_FAILURE), 'a failure audit row (with the user id) is written'); + } + + #[Test] + public function an_unknown_user_fails_identically_no_enumeration(): void + { + $unknown = 'ghost-' . bin2hex(random_bytes(6)) . '@example.test'; + + $result = $this->auth->login($unknown, 'whatever password'); + + // Same strict `false` shape as a wrong password — the response can't distinguish the two. + $this->assertFalse($result, 'an unknown identifier returns the same false as a wrong password'); + $this->assertFalse($this->auth->isAuthenticated()); + $this->assertSame(1, $this->auditCount(null, Tiger_Model_Login::RESULT_FAILURE, $unknown), 'the miss is audited with a null user_id'); + } + + #[Test] + public function an_inactive_user_cannot_log_in(): void + { + [, $email] = $this->makeUserWithPassword('suspended pw 123', 'suspended'); + + $this->assertFalse($this->auth->login($email, 'suspended pw 123'), 'a non-active user is refused even with the right password'); + $this->assertFalse($this->auth->isAuthenticated()); + } + + #[Test] + public function a_user_without_a_password_credential_cannot_password_login(): void + { + $email = $this->email(); + (new Tiger_Model_User())->insert(['email' => $email, 'status' => 'active']); // no credential + + $this->assertFalse($this->auth->login($email, 'anything at all'), 'no password credential => false'); + } + + // ----- brute-force lockout ---------------------------------------------- + + #[Test] + public function repeated_failures_lock_the_account_even_against_the_right_password(): void + { + [$uid, $email] = $this->makeUserWithPassword('unlock me please 1'); + + // Walk the credential to the lockout threshold with wrong passwords. + for ($i = 0; $i < Tiger_Model_UserCredential::MAX_FAILURES; $i++) { + $this->assertFalse($this->auth->login($email, 'nope nope nope ' . $i)); + } + + // Now the CORRECT password is refused — the lock trips before the password is even checked. + $this->assertFalse($this->auth->login($email, 'unlock me please 1'), 'a locked account refuses the correct password'); + $this->assertFalse($this->auth->isAuthenticated()); + $this->assertGreaterThanOrEqual(1, $this->auditCount($uid, Tiger_Model_Login::RESULT_LOCKED), 'the lockout is audited as a locked result'); + } + + // ----- pepper migration -------------------------------------------------- + + #[Test] + public function a_password_hashed_without_a_pepper_still_logs_in_after_a_pepper_is_added(): void + { + // Store the password with NO pepper — a legacy, un-peppered bcrypt hash. + $this->useCrypto(null); + [$uid, $email] = $this->makeUserWithPassword('legacy secret 88'); + $legacy = (string) (new Tiger_Model_UserCredential())->passwordCredential($uid)->secret; + + // Turn the pepper on; login must still succeed AND transparently migrate the stored hash. + $this->useCrypto(self::PEPPER); + $identity = $this->auth->login($email, 'legacy secret 88'); + + $this->assertIsObject($identity, 'a legacy hash still verifies once a pepper is configured'); + $migrated = (string) (new Tiger_Model_UserCredential())->passwordCredential($uid)->secret; + $this->assertNotSame($legacy, $migrated, 'the hash was re-hashed with the pepper on login'); + } + + // ----- one-time-code (passwordless) login -------------------------------- + + #[Test] + public function a_valid_emailed_login_code_signs_the_user_in(): void + { + $email = $this->email(); + $uid = (new Tiger_Model_User())->insert(['email' => $email, 'status' => 'active']); + // Seed the challenge directly with a known code (requestLoginCode's code is random + only hashed). + (new Tiger_Model_AuthChallenge())->issue($uid, 'email_login', '135790', 600); + + $identity = $this->auth->verifyLoginCode($email, '135790'); + + $this->assertIsObject($identity, 'the right code signs in passwordlessly'); + $this->assertSame($uid, $identity->user_id); + $this->assertTrue($this->auth->isAuthenticated()); + $this->assertSame(1, $this->auditCount($uid, Tiger_Model_Login::RESULT_SUCCESS), 'the OTP login is audited as a success'); + } + + #[Test] + public function a_wrong_then_reused_login_code_both_fail(): void + { + $email = $this->email(); + $uid = (new Tiger_Model_User())->insert(['email' => $email, 'status' => 'active']); + (new Tiger_Model_AuthChallenge())->issue($uid, 'email_login', '246800', 600); + + $this->assertFalse($this->auth->verifyLoginCode($email, '000000'), 'a wrong code fails'); + $this->assertIsObject($this->auth->verifyLoginCode($email, '246800'), 'the right code then works'); + $this->assertFalse((new Tiger_Service_Authentication())->verifyLoginCode($email, '246800'), 'single-use: the same code cannot be replayed'); + } + + #[Test] + public function requesting_a_login_code_is_a_silent_noop_for_unknown_or_inactive_users(): void + { + $model = new Tiger_Model_AuthChallenge(); + + // Unknown address — nothing is issued and no error surfaces. + $this->auth->requestLoginCode('nobody-' . bin2hex(random_bytes(6)) . '@example.test'); + + // Inactive user — also silent, no challenge. + $email = $this->email(); + $uid = (new Tiger_Model_User())->insert(['email' => $email, 'status' => 'suspended']); + $this->auth->requestLoginCode($email); + + $this->assertSame(0, $model->countRecent($uid, 'email_login', 3600), 'no code is issued for an inactive user (no enumeration)'); + } + + #[Test] + public function requesting_a_login_code_issues_one_for_an_active_user_and_respects_the_send_cap(): void + { + $email = $this->email(); + $uid = (new Tiger_Model_User())->insert(['email' => $email, 'status' => 'active']); + $model = new Tiger_Model_AuthChallenge(); + + $this->auth->requestLoginCode($email); + $this->assertSame(1, $model->countRecent($uid, 'email_login', 3600), 'an active user gets exactly one fresh code'); + + // Pre-fill to the cap, then a further request must NOT issue another. + for ($i = $model->countRecent($uid, 'email_login', 3600); $i < Tiger_Service_Authentication::OTP_SEND_CAP; $i++) { + $model->issue($uid, 'email_login', str_pad((string) $i, 6, '0', STR_PAD_LEFT), 600); + } + $atCap = $model->countRecent($uid, 'email_login', 3600); + $this->auth->requestLoginCode($email); + $this->assertSame($atCap, $model->countRecent($uid, 'email_login', 3600), 'at the hourly cap no further code is issued'); + } + + // ----- password reset ---------------------------------------------------- + + #[Test] + public function resetting_the_password_redeems_the_token_sets_the_new_password_and_clears_lockout(): void + { + [$uid, $email] = $this->makeUserWithPassword('old password one'); + $token = bin2hex(random_bytes(16)); + $cid = (new Tiger_Model_AuthChallenge())->issue($uid, 'password_reset', $token, 3600); + + $out = $this->auth->resetPassword($cid, $token, 'brand new password 2', 'brand new password 2'); + + $this->assertTrue($out['ok'], 'a valid reset succeeds'); + $this->assertNull($out['error']); + // The new password now logs in; the old one no longer does. + $this->assertIsObject((new Tiger_Service_Authentication())->login($email, 'brand new password 2'), 'the new password works'); + $this->assertFalse((new Tiger_Service_Authentication())->login($email, 'old password one'), 'the old password is dead'); + } + + #[Test] + public function a_reset_with_a_mismatched_confirmation_or_wrong_code_fails_without_burning_the_token(): void + { + [$uid] = $this->makeUserWithPassword('keep this one 11'); + $token = bin2hex(random_bytes(16)); + $cid = (new Tiger_Model_AuthChallenge())->issue($uid, 'password_reset', $token, 3600); + + // Mismatched confirmation is caught BEFORE the token is touched. + $mismatch = $this->auth->resetPassword($cid, $token, 'new one aaaa', 'new one bbbb'); + $this->assertFalse($mismatch['ok']); + $this->assertNotNull($mismatch['error']); + $this->assertNull((new Tiger_Model_AuthChallenge())->findById($cid)->consumed_at, 'a mismatch never consumes the token'); + + // A wrong code fails but (matching confirms) still doesn't let the real token through afterward being spent. + $wrong = $this->auth->resetPassword($cid, 'not-the-token', 'new one cccc', 'new one cccc'); + $this->assertFalse($wrong['ok']); + + // The genuine token still works — proving neither prior attempt burned it. + $good = $this->auth->resetPassword($cid, $token, 'the real new one', 'the real new one'); + $this->assertTrue($good['ok'], 'the real token still redeems after a mismatch + a wrong code'); + } + + #[Test] + public function a_reset_below_the_length_policy_is_rejected_before_the_token_is_spent(): void + { + [$uid] = $this->makeUserWithPassword('policy guard 123'); + $token = bin2hex(random_bytes(16)); + $cid = (new Tiger_Model_AuthChallenge())->issue($uid, 'password_reset', $token, 3600); + + $out = $this->auth->resetPassword($cid, $token, 'short', 'short'); // < 8 chars + + $this->assertFalse($out['ok'], 'a too-short password is rejected'); + $this->assertNull((new Tiger_Model_AuthChallenge())->findById($cid)->consumed_at, 'a weak password never burns the reset link'); + } + + #[Test] + public function requesting_a_password_reset_is_enumeration_safe(): void + { + $model = new Tiger_Model_AuthChallenge(); + + // Unknown + inactive both return void and issue no challenge. + $this->auth->requestPasswordReset('nobody-' . bin2hex(random_bytes(6)) . '@example.test', 'https://host'); + $email = $this->email(); + $uid = (new Tiger_Model_User())->insert(['email' => $email, 'status' => 'suspended']); + $this->auth->requestPasswordReset($email, 'https://host'); + + $this->assertSame(0, $model->countRecent($uid, 'password_reset', 3600), 'no reset challenge for an inactive account'); + } + + // ----- two-factor (TOTP) ------------------------------------------------- + + /** Give a user a confirmed TOTP factor; returns [secret, recoveryPlaintextCodes]. */ + private function enrollTotp(string $userId): array + { + $secret = Tiger_Auth_Totp::generateSecret(); + $recovery = ['aaaa11111', 'bbbb22222']; + $hashes = array_map( + fn ($c) => Tiger_Security::hashCode(strtolower(preg_replace('/[^a-z0-9]/i', '', $c)), 'recovery'), + $recovery + ); + (new Tiger_Model_UserCredential())->replaceTotp($userId, Tiger_Crypto::encrypt($secret), $hashes); + return [$secret, $recovery]; + } + + #[Test] + public function a_confirmed_totp_factor_turns_login_into_a_second_step(): void + { + [$uid, $email] = $this->makeUserWithPassword('two factor pw 12'); + $this->enrollTotp($uid); + + $result = $this->auth->login($email, 'two factor pw 12'); + + $this->assertSame(Tiger_Service_Authentication::TWOFA_REQUIRED, $result, 'a correct password with 2FA yields the TWOFA_REQUIRED sentinel'); + $this->assertTrue($this->auth->isTwoFactorPending(), 'a pending 2FA challenge is stashed for this session'); + $this->assertFalse($this->auth->isAuthenticated(), 'NO session is established on the password step alone'); + } + + #[Test] + public function a_live_totp_code_completes_the_second_step(): void + { + [$uid, $email] = $this->makeUserWithPassword('totp verify pw 1'); + [$secret] = $this->enrollTotp($uid); + + $this->assertSame(Tiger_Service_Authentication::TWOFA_REQUIRED, $this->auth->login($email, 'totp verify pw 1')); + + $code = Tiger_Auth_Totp::codeAt($secret, intdiv(time(), 30)); // the current authenticator code + $identity = $this->auth->verifyTwoFactor($code); + + $this->assertIsObject($identity, 'a valid TOTP code finishes the login'); + $this->assertSame($uid, $identity->user_id); + $this->assertTrue($this->auth->isAuthenticated(), 'the session is now established'); + $this->assertFalse($this->auth->isTwoFactorPending(), 'the pending challenge is cleared on success'); + } + + #[Test] + public function a_recovery_code_also_completes_the_second_step(): void + { + [$uid, $email] = $this->makeUserWithPassword('recovery pw 1234'); + [, $recovery] = $this->enrollTotp($uid); + + $this->assertSame(Tiger_Service_Authentication::TWOFA_REQUIRED, $this->auth->login($email, 'recovery pw 1234')); + + // Present the recovery code with the punctuation a UI shows — normalization must still match. + $identity = $this->auth->verifyTwoFactor('AAAA-11111'); + + $this->assertIsObject($identity, 'a single-use recovery code completes 2FA'); + $this->assertSame($uid, $identity->user_id); + $this->assertTrue($this->auth->isAuthenticated()); + } + + #[Test] + public function a_bad_2fa_code_keeps_the_pending_challenge_and_too_many_abandon_it(): void + { + [$uid, $email] = $this->makeUserWithPassword('bad code pw 1234'); + $this->enrollTotp($uid); + $this->auth->login($email, 'bad code pw 1234'); + + $this->assertFalse($this->auth->verifyTwoFactor('000000'), 'a wrong code fails'); + $this->assertTrue($this->auth->isTwoFactorPending(), 'a wrong code keeps the pending challenge so the user can retry'); + + // Exhaust the remaining guesses; once over the cap the challenge is abandoned. + for ($i = 0; $i <= Tiger_Service_Authentication::TWOFA_MAX_ATTEMPTS; $i++) { + $this->auth->verifyTwoFactor('000000'); + } + $this->assertFalse($this->auth->isTwoFactorPending(), 'too many guesses abandon the pending challenge'); + $this->assertFalse($this->auth->isAuthenticated(), 'and no session was ever established'); + } + + // ----- session lifecycle, lock, return-to, useOrg ------------------------ + + #[Test] + public function logout_clears_the_identity_and_the_actor(): void + { + [, $email] = $this->makeUserWithPassword('logout me now 12'); + $this->auth->login($email, 'logout me now 12'); + $this->assertTrue($this->auth->isAuthenticated()); + + $this->auth->logout(); + + $this->assertFalse($this->auth->isAuthenticated(), 'logout reverts to guest'); + $this->assertNull($this->auth->getIdentity()); + $this->assertNull(Tiger_Model_Table::actor(), 'the request actor is cleared on logout'); + } + + #[Test] + public function lock_holds_the_session_and_unlock_requires_the_right_password(): void + { + [, $email] = $this->makeUserWithPassword('lock screen pw 12'); + $this->auth->login($email, 'lock screen pw 12'); + + $this->auth->lock(); + $this->assertTrue($this->auth->isLocked(), 'the screen is locked while identity stays valid'); + + $this->assertFalse($this->auth->unlock('the wrong password'), 'a wrong password does not unlock'); + $this->assertTrue($this->auth->isLocked(), 'still locked after a bad attempt'); + + $this->assertTrue($this->auth->unlock('lock screen pw 12'), 'the right password unlocks'); + $this->assertFalse($this->auth->isLocked(), 'the screen is unlocked'); + $this->assertTrue($this->auth->isAuthenticated(), 'the identity was never dropped — a lock is not a logout'); + } + + #[Test] + public function set_and_take_return_to_round_trips_and_refuses_unsafe_paths(): void + { + $this->auth->setReturnTo('/dashboard/reports'); + $this->assertSame('/dashboard/reports', $this->auth->takeReturnTo(), 'a local path round-trips'); + $this->assertSame('', $this->auth->takeReturnTo(), 'and is cleared after being taken (single read)'); + + // Unsafe / loop-inducing targets are ignored (nothing stored). + $this->auth->setReturnTo('//evil.example/phish'); // protocol-relative + $this->assertSame('', $this->auth->takeReturnTo(), 'a protocol-relative path is refused'); + $this->auth->setReturnTo('/auth/login'); // an auth page (loop guard) + $this->assertSame('', $this->auth->takeReturnTo(), 'an /auth/ path is refused'); + $this->auth->setReturnTo('relative/no/slash'); // not root-local + $this->assertSame('', $this->auth->takeReturnTo(), 'a non-local path is refused'); + } + + #[Test] + public function use_org_switches_the_role_for_a_member_and_denies_a_non_member(): void + { + [$uid, $email] = $this->makeUserWithPassword('multi org pw 123'); + $orgA = $this->makeOrgMembership($uid, 'admin'); + $orgB = $this->makeOrgMembership($uid, 'viewer'); + // A third org the user is NOT a member of. + $orgC = (new Tiger_Model_Org())->insert(['name' => 'Outside', 'slug' => 'org-' . bin2hex(random_bytes(6))]); + + $this->auth->login($email, 'multi org pw 123'); + + $switched = $this->auth->useOrg($orgB); + $this->assertIsObject($switched, 'switching to another membership succeeds'); + $this->assertSame($orgB, $switched->org_id); + $this->assertSame('viewer', $switched->role, 'the role is re-resolved for the target org'); + $this->assertSame('viewer', $this->auth->getIdentity()->role, 'the switched identity is written back to the session'); + + $this->assertFalse($this->auth->useOrg($orgC), 'switching into an org you are not a member of is denied'); + // Sanity: orgA was the seed for coverage of the multi-membership case. + $this->assertNotSame($orgA, $orgB); + } + + #[Test] + public function two_factor_status_reflects_a_confirmed_factor(): void + { + [$uid, $email] = $this->makeUserWithPassword('status check pw 1'); + $this->auth->login($email, 'status check pw 1'); + $this->assertFalse($this->auth->getTwoFactorStatus()['enabled'], 'no factor yet => 2FA reported off'); + + $this->enrollTotp($uid); + $status = $this->auth->getTwoFactorStatus(); + $this->assertTrue($status['enabled'], 'a confirmed TOTP factor reports enabled'); + $this->assertSame(2, $status['recovery'], 'the remaining recovery-code count is reported'); + $this->assertTrue($status['available'], '2FA is available when crypto is configured'); + } +} diff --git a/tests/Integration/ServiceScaffoldTest.php b/tests/Integration/ServiceScaffoldTest.php new file mode 100644 index 0000000..e4575b0 --- /dev/null +++ b/tests/Integration/ServiceScaffoldTest.php @@ -0,0 +1,87 @@ +login('u-42', 'o-7', 'admin'); + + $identity = Zend_Auth::getInstance()->getIdentity(); + $this->assertIsObject($identity); + $this->assertSame('u-42', $identity->user_id); + $this->assertSame('o-7', $identity->org_id); + $this->assertSame('admin', $identity->role); + + // The model actor/org are stamped too — an insert now carries created_by/org_id. + $media = new \Tiger_Model_Media(); + $id = $media->insert(['org_id' => 'o-7', 'filename' => 's.txt', 'mime_type' => 'text/plain', 'storage_key' => 'k', 'disk' => 'local', 'kind' => 'file']); + $row = $media->find($id)->current(); + $this->assertSame('u-42', $row->created_by, 'actor stamped from the logged-in identity'); + } + + #[Test] + public function the_real_acl_policy_is_registered_and_decides(): void + { + $this->loginAs('admin'); + $acl = Zend_Registry::get('Zend_Acl'); + + // The shipped modules/access/configs/acl.ini allows admin on the service, denies everyone below. + $this->assertTrue($acl->has('Access_Service_User'), 'the module resource loaded from acl.ini'); + $this->assertTrue($acl->isAllowed('admin', 'Access_Service_User'), 'admin allowed per shipped rule'); + $this->assertFalse($acl->isAllowed('guest', 'Access_Service_User'), 'guest denied per shipped rule'); + $this->assertFalse($acl->isAllowed('user', 'Access_Service_User'), 'a plain user is denied too'); + } + + #[Test] + public function guest_is_denied_dispatching_a_real_admin_service(): void + { + // No login → guest. The service's _isAdmin() gate must refuse before doing any work. + $this->login('anon', 'o-1', 'guest'); + $res = (new Access_Service_User(['action' => 'datatable']))->getResponse(); + + $this->assertSame(0, (int) $res->result, 'guest is denied'); + $messages = json_encode($res->messages ?? []); + $this->assertStringContainsString('not_allowed', $messages, 'the ACL denial fired'); + } + + #[Test] + public function an_admin_passes_the_gate_on_the_same_service(): void + { + $this->loginAs('admin'); + $res = (new Access_Service_User(['action' => 'datatable', 'draw' => 1, 'start' => 0, 'length' => 10]))->getResponse(); + + // We don't assert the payload shape here (that's the service's own test) — only that the ACL + // gate did NOT deny an admin: the response is not the not_allowed refusal. + $this->assertStringNotContainsString('not_allowed', json_encode($res->messages ?? []), 'admin cleared the gate'); + } + + #[Test] + public function logout_reverts_to_guest(): void + { + $this->loginAs('admin'); + $this->assertIsObject(Zend_Auth::getInstance()->getIdentity()); + $this->logout(); + $this->assertNull(Zend_Auth::getInstance()->getIdentity(), 'identity cleared → guest'); + } +} diff --git a/tests/Integration/Signup/SignupServiceTest.php b/tests/Integration/Signup/SignupServiceTest.php new file mode 100644 index 0000000..3e4b30c --- /dev/null +++ b/tests/Integration/Signup/SignupServiceTest.php @@ -0,0 +1,422 @@ +_priorConfig = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + Zend_Registry::set('Zend_Config', new Zend_Config([ + 'tiger' => ['password' => ['min_length' => 8, 'require_complexity' => 0, 'history' => 0, 'max_age_days' => 0]], + ])); + } + + protected function tearDown(): void + { + // Undo any REAL rows a committing (happy-path) test wrote outside the base transaction. + foreach ($this->_committed as $userId) { + $this->purgeTenant($userId); + } + $this->_committed = []; + Zend_Registry::set('tiger.auth.stateless', false); + Zend_Registry::set('Zend_Config', $this->_priorConfig ?? new Zend_Config([])); + parent::tearDown(); + } + + // ---- helpers ------------------------------------------------------------ + + /** A complete, valid signup payload with a per-call unique tag; $overrides replace any field. */ + private function validParams(array $overrides = []): array + { + $tag = bin2hex(random_bytes(6)); + return array_merge([ + 'action' => 'create', + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'company' => 'Acme ' . $tag, + 'username' => 'jane' . $tag, + 'password' => 'SuperSecret123', + 'email' => 'jane' . $tag . '@example.test', + 'street' => '123 Main St', + 'city' => 'Springfield', + 'region' => 'IL', + 'postal' => '62704', + 'country' => 'US', + 'phone_type' => 'mobile', + 'phone' => '+1 555 123 4567', + ], $overrides); + } + + /** Dispatch create with CSRF skipped (stateless) — for validation/flag tests that never commit. */ + private function dispatch(array $params): Tiger_Model_ResponseObject + { + Zend_Registry::set('tiger.auth.stateless', true); + return (new Signup_Service_Signup($params))->getResponse(); + } + + /** Dispatch create for REAL: skip CSRF, end the base txn so `_transaction()` can begin+commit. */ + private function dispatchCommitting(array $params): Tiger_Model_ResponseObject + { + Zend_Registry::set('tiger.auth.stateless', true); + try { $this->db->rollBack(); } catch (\Throwable $e) { /* base txn already closed */ } + $res = (new Signup_Service_Signup($params))->getResponse(); + $userId = $this->db->fetchOne('SELECT user_id FROM user WHERE email = ?', [strtolower($params['email'] ?? '')]); + if ($userId) { $this->_committed[] = $userId; } // register for cleanup regardless of assertions + return $res; + } + + /** Hard-delete a signup's whole tenant graph (committed rows) so the shared DB stays clean. */ + private function purgeTenant(string $userId): void + { + $orgId = $this->db->fetchOne('SELECT org_id FROM org_user WHERE user_id = ?', [$userId]); + $addressIds = $this->db->fetchCol('SELECT address_id FROM user_address WHERE user_id = ?', [$userId]); + $contactIds = $this->db->fetchCol('SELECT contact_id FROM user_contact WHERE user_id = ?', [$userId]); + foreach (['org_user', 'user_credential', 'user_address', 'user_contact', 'auth_challenge'] as $t) { + $this->db->query("DELETE FROM $t WHERE user_id = ?", [$userId]); + } + $this->db->query('DELETE FROM user WHERE user_id = ?', [$userId]); + if ($orgId) { + foreach (['org_address', 'org_contact', 'org_user'] as $t) { + $this->db->query("DELETE FROM $t WHERE org_id = ?", [$orgId]); + } + $this->db->query('DELETE FROM org WHERE org_id = ?', [$orgId]); + } + foreach ($addressIds as $aid) { $this->db->query('DELETE FROM address WHERE address_id = ?', [$aid]); } + foreach ($contactIds as $cid) { $this->db->query('DELETE FROM contact WHERE contact_id = ?', [$cid]); } + } + + private function messagesJson(Tiger_Model_ResponseObject $res): string + { + return json_encode($res->messages ?? []); + } + + // ---- happy path --------------------------------------------------------- + + #[Test] + public function a_valid_guest_signup_builds_the_whole_tenant_graph(): void + { + $p = $this->validParams(); + $res = $this->dispatchCommitting($p); + + // Envelope: success + the "check your email" message + the sent/email payload. + $this->assertSame(1, (int) $res->result, 'a valid signup succeeds'); + $this->assertStringContainsString('signup.check_email', $this->messagesJson($res), 'the check-email message is returned'); + $this->assertSame(1, (int) $res->data['sent'], 'the payload reports the verification was sent'); + $this->assertSame(strtolower($p['email']), $res->data['email'], 'the payload echoes the (lowercased) email'); + + // user — created PENDING (guest until the email is verified), not active. + $user = $this->db->fetchRow('SELECT * FROM user WHERE email = ?', [strtolower($p['email'])]); + $this->assertNotEmpty($user, 'the user row exists'); + $this->assertSame('pending', $user['status'], 'the account is pending until email verification'); + $this->assertSame($p['username'], $user['username'], 'the chosen username is stored'); + + // org — name kept, slug derived from the company name. + $ou = $this->db->fetchRow('SELECT * FROM org_user WHERE user_id = ?', [$user['user_id']]); + $this->assertNotEmpty($ou, 'a membership row links the user to an org'); + $this->assertSame('user', $ou['role'], 'the founder joins their org as role=user'); + $org = $this->db->fetchRow('SELECT * FROM org WHERE org_id = ?', [$ou['org_id']]); + $this->assertSame($p['company'], $org['name'], 'the company name becomes the org name'); + $this->assertSame(\Tiger_Install::slugify($p['company']), $org['slug'], 'the slug is derived from the company'); + + // credential — a verified password factor (verified_at stamped at set). + $cred = $this->db->fetchRow('SELECT * FROM user_credential WHERE user_id = ?', [$user['user_id']]); + $this->assertSame('password', $cred['type'], 'a password credential is stored'); + $this->assertNotNull($cred['verified_at'], 'the password factor is marked verified'); + $this->assertNotSame($p['password'], $cred['secret'], 'the password is hashed, never stored in the clear'); + + // address + contact — each linked to BOTH the org and the user. + $this->assertNotEmpty($this->db->fetchOne('SELECT 1 FROM user_address WHERE user_id = ?', [$user['user_id']]), 'user_address join written'); + $this->assertNotEmpty($this->db->fetchOne('SELECT 1 FROM org_address WHERE org_id = ?', [$ou['org_id']]), 'org_address join written'); + $this->assertNotEmpty($this->db->fetchOne('SELECT 1 FROM user_contact WHERE user_id = ?', [$user['user_id']]), 'user_contact join written'); + $this->assertNotEmpty($this->db->fetchOne('SELECT 1 FROM org_contact WHERE org_id = ?', [$ou['org_id']]), 'org_contact join written'); + + // an email_verify challenge was issued for the verification link. + $this->assertSame('email_verify', $this->db->fetchOne('SELECT type FROM auth_challenge WHERE user_id = ?', [$user['user_id']]), 'an email_verify challenge is issued'); + } + + #[Test] + public function a_self_signup_stamps_no_actor_and_leaves_locale_timezone_unset(): void + { + // No login() → a true anonymous guest: there is no authenticated actor, so created_by is NULL + // (system/genesis), and signup collects no locale/timezone, so those carve-out columns stay unset. + $p = $this->validParams(); + $res = $this->dispatchCommitting($p); + $this->assertSame(1, (int) $res->result); + + $user = $this->db->fetchRow('SELECT created_by, locale, timezone FROM user WHERE email = ?', [strtolower($p['email'])]); + $this->assertNull($user['created_by'], 'a self-signup has no actor to stamp — created_by is NULL'); + $this->assertNull($user['locale'], 'signup does not set the user locale'); + $this->assertNull($user['timezone'], 'signup does not set the user timezone'); + + $org = $this->db->fetchOne('SELECT created_by FROM org WHERE org_id = (SELECT org_id FROM org_user WHERE user_id = (SELECT user_id FROM user WHERE email = ?))', [strtolower($p['email'])]); + $this->assertNull($org, 'the org row is likewise unattributed (created_by NULL)'); + } + + #[Test] + public function the_email_is_normalized_to_lowercase(): void + { + $tag = bin2hex(random_bytes(6)); + $p = $this->validParams(['email' => 'MixedCase' . $tag . '@Example.TEST', 'username' => 'mc' . $tag]); + $res = $this->dispatchCommitting($p); + + $this->assertSame(1, (int) $res->result, 'a valid signup with an upper-case email succeeds'); + $this->assertSame('mixedcase' . $tag . '@example.test', $res->data['email'], 'the returned email is lowercased'); + $this->assertNotEmpty( + $this->db->fetchOne('SELECT 1 FROM user WHERE email = ?', ['mixedcase' . $tag . '@example.test']), + 'the stored email is lowercased' + ); + } + + // ---- validation failures (reach the form, never the transaction) -------- + + #[Test] + public function a_missing_email_is_a_keyed_form_error_and_writes_nothing(): void + { + $p = $this->validParams(); + unset($p['email']); + $res = $this->dispatch($p); + + $this->assertSame(0, (int) $res->result, 'a missing email fails'); + $this->assertArrayHasKey('email', (array) $res->form, 'the error is keyed to the email field'); + $this->assertStringContainsString('core.api.error.form', $this->messagesJson($res), 'the generic form-error message is returned'); + $this->assertNoTenantFor($p['username'], $p['company']); + } + + #[Test] + public function an_invalid_email_format_is_rejected(): void + { + $res = $this->dispatch($this->validParams(['email' => 'not-an-email'])); + + $this->assertSame(0, (int) $res->result); + $this->assertArrayHasKey('email', (array) $res->form, 'a malformed email is a field error'); + } + + #[Test] + public function a_password_that_violates_policy_is_rejected(): void + { + // The policy floor is 8 chars (Tiger_Policy_Password default) — a 5-char password fails. + $res = $this->dispatch($this->validParams(['password' => 'short'])); + + $this->assertSame(0, (int) $res->result, 'a weak password fails'); + $this->assertArrayHasKey('password', (array) $res->form, 'the error is keyed to the password field'); + } + + #[Test] + public function a_username_that_breaks_the_pattern_is_rejected(): void + { + // The username regex is /^[a-zA-Z0-9._-]{3,32}$/ — a space is illegal. + $res = $this->dispatch($this->validParams(['username' => 'has space'])); + + $this->assertSame(0, (int) $res->result); + $this->assertArrayHasKey('username', (array) $res->form, 'an illegal username character is a field error'); + } + + #[Test] + public function an_invalid_country_is_rejected(): void + { + // 'QZ' is not in Tiger_I18n_Country::codes() → the InArray validator fails. + $res = $this->dispatch($this->validParams(['country' => 'QZ'])); + + $this->assertSame(0, (int) $res->result); + $this->assertArrayHasKey('country', (array) $res->form, 'an unknown country code is a field error'); + } + + #[Test] + public function a_malformed_phone_is_rejected(): void + { + $res = $this->dispatch($this->validParams(['phone' => 'call-me'])); + + $this->assertSame(0, (int) $res->result); + $this->assertArrayHasKey('phone', (array) $res->form, 'a non-numeric phone is a field error'); + } + + // ---- DB-uniqueness (the NoRecordExists validators) ---------------------- + + #[Test] + public function a_duplicate_email_is_caught_before_any_write(): void + { + // Seed an existing account (inside the base txn — rolled back after the test). + $p = $this->validParams(); + (new Tiger_Model_User())->insert(['email' => strtolower($p['email']), 'username' => 'pre' . bin2hex(random_bytes(4))]); + + $res = $this->dispatch($p); + + $this->assertSame(0, (int) $res->result, 'a taken email fails'); + $this->assertArrayHasKey('email', (array) $res->form, 'the collision is reported on the email field'); + // No second (signup-created) account, and no org, exists for this attempt. + $this->assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM user WHERE username = ?', [$p['username']]), 'the signup created no user'); + $this->assertNoTenantFor($p['username'], $p['company']); + } + + #[Test] + public function a_duplicate_username_is_caught_before_any_write(): void + { + $p = $this->validParams(); + (new Tiger_Model_User())->insert(['email' => 'other' . bin2hex(random_bytes(4)) . '@example.test', 'username' => $p['username']]); + + $res = $this->dispatch($p); + + $this->assertSame(0, (int) $res->result, 'a taken username fails'); + $this->assertArrayHasKey('username', (array) $res->form, 'the collision is reported on the username field'); + // The seed is the ONLY holder of the username — the signup added none — and no org was created. + $this->assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM user WHERE username = ?', [$p['username']]), 'only the pre-seeded user holds the username'); + $this->assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM org WHERE name = ?', [$p['company']]), 'no org row was written'); + } + + // ---- the "public signup disabled" flag ---------------------------------- + + #[Test] + public function the_admin_disable_flag_refuses_signup_before_validation(): void + { + (new Tiger_Model_Option())->set(Tiger_Model_Option::SCOPE_GLOBAL, '', 'signup.public_disabled', '1'); + + // Even a perfectly valid payload is refused — the flag is not cosmetic, and it short-circuits + // ahead of the form, so nothing is created. + $p = $this->validParams(); + $res = $this->dispatch($p); + + $this->assertSame(0, (int) $res->result, 'signup is refused when disabled'); + $this->assertStringContainsString('signup.disabled', $this->messagesJson($res), 'the "turned off" message is returned'); + $this->assertNull($res->form, 'the refusal is not a form error'); + $this->assertNoTenantFor($p['username'], $p['company']); + } + + #[Test] + public function signup_proceeds_when_the_disable_flag_is_absent(): void + { + // Default state (no option row): isPublicDisabled() is false, so a valid payload passes the gate. + $this->assertFalse(Signup_Service_Signup::isPublicDisabled(), 'public signup is enabled by default'); + } + + // ---- CSRF gate ---------------------------------------------------------- + + #[Test] + public function without_a_csrf_token_the_form_is_refused(): void + { + // NOT stateless: the reference form carries a CSRF hash element, and a bodyless /api call has no + // token — the base maps that to the dedicated csrf message (not a per-field error). + Zend_Registry::set('tiger.auth.stateless', false); + $p = $this->validParams(); + $res = (new Signup_Service_Signup($p))->getResponse(); + + $this->assertSame(0, (int) $res->result, 'a missing CSRF token fails the call'); + $this->assertStringContainsString('core.api.error.csrf', $this->messagesJson($res), 'the CSRF-specific message fires'); + $this->assertNoTenantFor($p['username'], $p['company']); + } + + // ---- ACL posture (guest is allowed to sign up) -------------------------- + + #[Test] + public function the_shipped_acl_allows_a_guest_to_reach_the_signup_service(): void + { + $this->login('anon', 'org-test', 'guest'); + $acl = Zend_Registry::get('Zend_Acl'); + + $this->assertTrue($acl->has('Signup_Service_Signup'), 'the service is a governed resource (loaded from signup/acl.ini)'); + $this->assertTrue($acl->isAllowed('guest', 'Signup_Service_Signup'), 'a guest is allowed — public signup'); + } + + #[Test] + public function a_guest_dispatch_is_not_denied_by_the_acl(): void + { + // The create() method carries no in-service ACL gate (the /api ServiceFactory authorizes it), + // so a guest dispatch reaches the form — it must never come back as the not_allowed refusal. + $this->login('anon', 'org-1', 'guest'); + $res = $this->dispatch($this->validParams(['password' => 'short'])); // fails at the FORM, not the ACL + + $this->assertStringNotContainsString('not_allowed', $this->messagesJson($res), 'a guest is not denied — it reaches the service'); + $this->assertArrayHasKey('password', (array) $res->form, 'proof the dispatch ran the form (not an ACL bounce)'); + } + + // ---- verifyEmail (direct call — the controller invokes it, not /api) ---- + + #[Test] + public function verify_email_redeems_a_valid_challenge_and_activates_the_account(): void + { + // Build a pending account + its email_verify challenge (inside the base txn). + $userId = (new Tiger_Model_User())->insert(['email' => 'verify' . bin2hex(random_bytes(5)) . '@example.test', 'status' => 'pending']); + $token = bin2hex(random_bytes(16)); + $cid = (new Tiger_Model_AuthChallenge())->issue($userId, 'email_verify', $token, 86400); + + $result = (new Signup_Service_Signup())->verifyEmail($cid, $token); + + $this->assertTrue($result['ok'], 'a correct token verifies'); + $this->assertSame($userId, $result['user_id'], 'the owning user is returned'); + $this->assertSame('active', $this->db->fetchOne('SELECT status FROM user WHERE user_id = ?', [$userId]), 'the account is flipped to active'); + } + + #[Test] + public function verify_email_rejects_a_wrong_code_and_leaves_the_account_pending(): void + { + $userId = (new Tiger_Model_User())->insert(['email' => 'verify' . bin2hex(random_bytes(5)) . '@example.test', 'status' => 'pending']); + $token = bin2hex(random_bytes(16)); + $cid = (new Tiger_Model_AuthChallenge())->issue($userId, 'email_verify', $token, 86400); + + $result = (new Signup_Service_Signup())->verifyEmail($cid, 'the-wrong-code'); + + $this->assertFalse($result['ok'], 'a wrong token does not verify'); + $this->assertSame('pending', $this->db->fetchOne('SELECT status FROM user WHERE user_id = ?', [$userId]), 'the account stays pending'); + } + + #[Test] + public function verify_email_is_safe_against_an_unknown_challenge_id(): void + { + $result = (new Signup_Service_Signup())->verifyEmail('not-a-real-challenge', 'whatever'); + $this->assertFalse($result['ok'], 'an unknown challenge id resolves to a clean failure, no throw'); + } + + // ---- shared assertion --------------------------------------------------- + + /** Assert a rejected signup left NO tenant rows behind (no user by username, no org by company). */ + private function assertNoTenantFor(string $username, string $company): void + { + $this->assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM user WHERE username = ?', [$username]), 'no user row was written'); + $this->assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM org WHERE name = ?', [$company]), 'no org row was written'); + } +} diff --git a/tests/Integration/System/ModulesServiceTest.php b/tests/Integration/System/ModulesServiceTest.php new file mode 100644 index 0000000..fba2230 --- /dev/null +++ b/tests/Integration/System/ModulesServiceTest.php @@ -0,0 +1,137 @@ +getResponse(); + } + + private function messages(object $res): string + { + return json_encode($res->messages ?? []); + } + + // ---- ACL: deny-by-default, superadmin-gated -------------------------------------------------- + + #[Test] + public function the_shipped_acl_gates_module_management_to_superadmin_and_up(): void + { + $this->loginAs('superadmin'); + $acl = Zend_Registry::get('Zend_Acl'); + + $this->assertTrue($acl->has('System_Service_Modules'), 'the system module acl.ini resource loaded'); + $this->assertTrue($acl->isAllowed('superadmin', 'System_Service_Modules'), 'superadmin manages modules'); + $this->assertTrue($acl->isAllowed('developer', 'System_Service_Modules'), 'the god developer role inherits it'); + $this->assertFalse($acl->isAllowed('admin', 'System_Service_Modules'), 'a plain admin cannot install/toggle modules'); + $this->assertFalse($acl->isAllowed('guest', 'System_Service_Modules'), 'a guest is denied'); + } + + #[Test] + public function a_guest_is_denied_toggling_a_module(): void + { + $this->login('anon', 'o-1', 'guest'); + $res = $this->dispatch(['action' => 'deactivate', 'slug' => 'blog']); + + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('not_allowed', $this->messages($res), 'the ACL denial fired'); + } + + #[Test] + public function a_plain_admin_is_denied_installing_a_module(): void + { + $this->loginAs('admin'); + $res = $this->dispatch(['action' => 'install', 'url' => 'https://github.com/WebTigers/TigerShop']); + + $this->assertSame(0, (int) $res->result, 'installing code is superadmin+, not admin'); + $this->assertStringContainsString('not_allowed', $this->messages($res), 'the ACL denial fired for admin'); + } + + // ---- the guard rails (superadmin, no network) ------------------------------------------------ + + #[Test] + public function the_protected_set_can_never_be_deactivated(): void + { + $this->loginAs('superadmin'); + foreach (System_Service_Modules::PROTECTED as $slug) { + $res = $this->dispatch(['action' => 'deactivate', 'slug' => $slug]); + $this->assertSame(0, (int) $res->result, "protected module '$slug' is not deactivatable"); + $this->assertStringContainsString('protected', $this->messages($res), "the protected-guard fired for '$slug'"); + $this->assertStringNotContainsString('not_allowed', $this->messages($res), 'superadmin cleared the ACL gate first'); + } + } + + #[Test] + public function an_unknown_slug_is_refused_before_any_migration(): void + { + $this->loginAs('superadmin'); + // A slug that is neither protected nor discovered on disk — refused at the discovery check, + // which is BEFORE Tiger_Module_Installer::migrateModule() would ever run. + $res = $this->dispatch(['action' => 'activate', 'slug' => 'no-such-module-xyz']); + + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('unknown', $this->messages($res), 'the unknown-module guard fired'); + } + + #[Test] + public function an_empty_slug_is_a_clean_error(): void + { + $this->loginAs('superadmin'); + $res = $this->dispatch(['action' => 'deactivate', 'slug' => '']); + + $this->assertSame(0, (int) $res->result, 'an empty slug fails cleanly'); + $this->assertStringNotContainsString('not_allowed', $this->messages($res), 'it failed on validation, not the ACL'); + } + + #[Test] + public function install_rejects_a_non_github_url_before_any_network(): void + { + $this->loginAs('superadmin'); + $res = $this->dispatch(['action' => 'install', 'url' => 'not a url at all']); + + $this->assertSame(0, (int) $res->result, 'a malformed repo URL is refused'); + $this->assertStringContainsString('GitHub', $this->messages($res), 'the URL-shape guard, not a network failure'); + } + + #[Test] + public function inspect_rejects_a_non_github_url_before_any_network(): void + { + $this->loginAs('superadmin'); + $res = $this->dispatch(['action' => 'inspect', 'url' => 'ftp://example.com/whatever']); + + $this->assertSame(0, (int) $res->result, 'inspect refuses a non-GitHub URL up front'); + $this->assertStringContainsString('GitHub', $this->messages($res)); + } + + #[Test] + public function upload_with_no_file_is_a_clean_error(): void + { + $this->loginAs('superadmin'); + // No $_FILES['archive'] present → the "choose a .zip" guard, never a fatal. + unset($_FILES['archive']); + $res = $this->dispatch(['action' => 'upload']); + + $this->assertSame(0, (int) $res->result, 'a missing upload fails cleanly'); + $this->assertStringNotContainsString('not_allowed', $this->messages($res), 'it failed on the missing file, not the ACL'); + } +} diff --git a/tests/Integration/System/UpdatesServiceTest.php b/tests/Integration/System/UpdatesServiceTest.php new file mode 100644 index 0000000..f379523 --- /dev/null +++ b/tests/Integration/System/UpdatesServiceTest.php @@ -0,0 +1,91 @@ +getResponse(); + } + + private function messages(object $res): string + { + return json_encode($res->messages ?? []); + } + + #[Test] + public function the_shipped_acl_gates_the_updater_to_superadmin_and_up(): void + { + $this->loginAs('superadmin'); + $acl = Zend_Registry::get('Zend_Acl'); + + $this->assertTrue($acl->has('System_Service_Updates'), 'the updates acl.ini resource loaded'); + $this->assertTrue($acl->isAllowed('superadmin', 'System_Service_Updates'), 'superadmin may self-update'); + $this->assertTrue($acl->isAllowed('developer', 'System_Service_Updates'), 'the god developer role inherits it'); + $this->assertFalse($acl->isAllowed('admin', 'System_Service_Updates'), 'a plain admin cannot apply updates'); + $this->assertFalse($acl->isAllowed('guest', 'System_Service_Updates'), 'a guest is denied'); + } + + #[Test] + public function a_guest_is_denied_applying_updates(): void + { + $this->login('anon', 'o-1', 'guest'); + $res = $this->dispatch(['action' => 'apply', 'items' => 'tiger-core']); + + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('not_allowed', $this->messages($res), 'the ACL denial fired'); + } + + #[Test] + public function a_plain_admin_is_denied_applying_updates(): void + { + $this->loginAs('admin'); + $res = $this->dispatch(['action' => 'apply', 'items' => 'tiger-core']); + + $this->assertSame(0, (int) $res->result, 'applying updates is superadmin+, not admin'); + $this->assertStringContainsString('not_allowed', $this->messages($res), 'the ACL denial fired for admin'); + } + + #[Test] + public function apply_with_no_selection_is_a_clean_error_not_a_crash(): void + { + // Superadmin clears the gate; the empty-selection guard fires BEFORE any update engine runs + // (so this needs no network). + $this->loginAs('superadmin'); + $res = $this->dispatch(['action' => 'apply', 'items' => []]); + + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('none_selected', $this->messages($res), 'the empty-selection guard fired'); + $this->assertStringNotContainsString('not_allowed', $this->messages($res), 'superadmin cleared the ACL gate first'); + } + + #[Test] + public function history_returns_a_list_for_a_superadmin(): void + { + // history() is fail-soft: it returns [] even if the table isn't migrated — never an error. + $this->loginAs('superadmin'); + $res = $this->dispatch(['action' => 'history', 'limit' => 5]); + + $this->assertSame(1, (int) $res->result, 'history is a read that always succeeds'); + $this->assertIsArray($res->data['history'], 'the payload carries a history list'); + } +} diff --git a/tests/Support/IntegrationTestCase.php b/tests/Support/IntegrationTestCase.php index 518d460..2b3a598 100644 --- a/tests/Support/IntegrationTestCase.php +++ b/tests/Support/IntegrationTestCase.php @@ -5,8 +5,11 @@ namespace Tiger\Tests\Support; use PHPUnit\Framework\TestCase; +use Tiger_Acl_Acl; use Tiger_Db_Migrator; use Tiger_Model_Table; +use Zend_Auth; +use Zend_Auth_Storage_NonPersistent; use Zend_Db; use Zend_Db_Adapter_Abstract; use Zend_Db_Table_Abstract; @@ -65,9 +68,55 @@ protected function tearDown(): void } Tiger_Model_Table::setActor(null); Tiger_Model_Table::setOrg(''); + Zend_Auth::getInstance()->clearIdentity(); // Zend_Auth is a process singleton — never leak an identity parent::tearDown(); } + /** + * Sign a caller in for the duration of a test (Wave 3 `/api`-service scaffolding). + * + * Sets a non-persistent `Zend_Auth` identity ({user_id, org_id, role}) so a service's + * `_isAdmin()`/ACL gate and `$this->_user_id`/`_org_id` see an authenticated actor, stamps the + * model actor + org so writes carry `created_by`/`org_id`, and registers the **real** shipped ACL + * policy (`Tiger_Acl_Acl` — core + every module's `acl.ini`) so `isAllowed()` reflects the rules + * that actually ship, not a fixture. Cleared in tearDown. Call with role `guest` (or don't call it) + * to test the deny path. Returns the identity object. + * + * @param string $userId the acting user id + * @param string $orgId the acting org (tenant) id + * @param string $role the ACL role (guest|user|manager|supermanager|admin|superadmin|developer) + * @return object the identity written to Zend_Auth + */ + protected function login(string $userId, string $orgId = 'org-test', string $role = 'user'): object + { + $identity = (object) ['user_id' => $userId, 'org_id' => $orgId, 'role' => $role]; + + $auth = Zend_Auth::getInstance(); + $auth->setStorage(new Zend_Auth_Storage_NonPersistent()); // in-memory: no $_SESSION in CLI + $auth->getStorage()->write($identity); + + Tiger_Model_Table::setActor($userId); + if ($orgId !== '') { Tiger_Model_Table::setOrg($orgId); } + + // The real policy (ini + DB tiers). Rebuilt per login so a test that seeds DB rules first sees them. + Zend_Registry::set('Zend_Acl', new Tiger_Acl_Acl()); + + return $identity; + } + + /** Shorthand: sign in a synthetic user carrying $role, in the shared test org. */ + protected function loginAs(string $role): object + { + return $this->login('user-' . $role, 'org-test', $role); + } + + /** Drop the signed-in identity (revert to guest) mid-test. */ + protected function logout(): void + { + Zend_Auth::getInstance()->clearIdentity(); + Zend_Auth::getInstance()->setStorage(new Zend_Auth_Storage_NonPersistent()); + } + /** The shared, migrated adapter (built once, reused across the process). */ private static function adapter(string $name): Zend_Db_Adapter_Abstract { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index dbd0dc9..4a9c2d1 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -57,3 +57,39 @@ get_include_path(), ]; set_include_path(implode(PATH_SEPARATOR, array_filter($includePaths))); + +// Never send real mail in tests. Tiger_Mail otherwise resolves PHP mail()/sendmail (which fails in +// CI with no MTA — the swallow-and-error_log path then prints during a test and PHPUnit flags it +// "risky"). Point the wrapper's default transport at a File transport writing to a throwaway dir, so +// every send() captures-instead-of-delivers and never fails. (A per-call transport still overrides.) +$mailDir = sys_get_temp_dir() . '/tiger-test-mail'; +if (!is_dir($mailDir)) { @mkdir($mailDir, 0777, true); } +Tiger_Mail::setDefaultTransport(new Zend_Mail_Transport_File(['path' => $mailDir])); + +// --- Module class autoloader (ZF1 module convention) ------------------------------------------------ +// A module's classes (`Signup_Service_Signup`, `Signup_Form_Signup`, `Access_UserController`, …) live at +// `modules///.php` and are NOT on the composer/PSR-0 path the app resolves via ZF1's +// module resource loader (which the test harness deliberately doesn't boot). Registered LAST, so it only +// fires after the Tiger_*/Zend_* loaders miss — it never hijacks a framework class (whose first segment +// isn't a module dir). Lets an integration test instantiate a real /api service + its form/model. +spl_autoload_register(static function ($class) { + if (strpos($class, '_') === false) { return; } + $parts = explode('_', $class); + $mod = strtolower($parts[0]); + + $base = null; + foreach ([APPLICATION_PATH . '/modules/' . $mod, TIGER_CORE_PATH . '/modules/' . $mod] as $cand) { + if (is_dir($cand)) { $base = $cand; break; } // app module wins over a first-party one + } + if ($base === null) { return; } + + if (count($parts) === 2 && substr($parts[1], -10) === 'Controller') { + $file = $base . '/controllers/' . $parts[1] . '.php'; // Access_UserController → controllers/UserController.php + } else { + static $types = ['Service' => 'services', 'Form' => 'forms', 'Model' => 'models', 'Widget' => 'widgets', 'Plugin' => 'plugins']; + $type = $types[$parts[1]] ?? null; + if ($type === null) { return; } + $file = $base . '/' . $type . '/' . implode('/', array_slice($parts, 2)) . '.php'; // Signup_Service_Signup → services/Signup.php + } + if (is_file($file)) { require $file; } +});