From fe833e0c149a0facf936c8a7d28e987d009372c1 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 24 Jul 2026 13:03:43 -0400 Subject: [PATCH 1/2] =?UTF-8?q?test:=20Wave=202=20backfill=20=E2=80=94=20m?= =?UTF-8?q?odel/DB=20integration=20P1=20spine=20(102=20tests)=20+=20Passwo?= =?UTF-8?q?rdHistory=20limit=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four parallel integration clusters (each against an isolated DB during dev, verified together against one DB at collection: 111 tests / 374 assertions green): - Data-layer base (28): Tiger_Model_Table tested hardest — v7/v4 UUID mint, actor+org tenant stamp (immutable created_by), soft-delete + activeSelect/findById exclusion (the tenant-safety invariant); Config/Option scope isolation; Db_Migrator ordering/idempotency/apply-after-success/rollback. - Auth models (21): AuthChallenge single-use/expiry/attempt-lock/hashed-at-rest; UserCredential password verify + pepper-migration rehash + PAT/recovery single-use + lockout + TOTP encrypt-at-rest; User verified-factor identity; PasswordHistory ordering/prune; Policy_Password reuse across the pepper boundary. - Content/CMS (27): Page org cascade + publish/schedule gate + phtml-stored-not-executed; Media public-direct-vs-private-streamer URL scoping + classify allowlist; Menu tenant cascade + reorder ownership guard; Org siteOrgId/slug/hierarchy; Page/CodeVersion nextVersion. - ACL/module/session (26): Acl{Resource,Role,Rule} soft-delete exclusion (a dropped rule can't keep granting); Module inactiveSlugs boot gate (active-by-absence); Login windowed failure math; Session gc per-row expiry + deleteByUserId; Translation override tier. FIX (a test surfaced it): Tiger_Model_PasswordHistory::recentForUser() ignored its $limit — a Select passed to fetchAll() makes ZF1 drop the $count arg, so it returned ALL retained rows (stricter-than- configured, not a hole). Moved the limit ONTO the Select; the regression test now pins that it takes. Findings tracked (no change): ACL loaders filter deleted=0 only, not status (a status='inactive' rule still loads); AuthChallenge::redeem() single-use is a non-atomic TOCTOU (fine single-threaded, wants a conditional UPDATE under load). See COVERAGE-PLAN §9. Co-Authored-By: Claude Opus 4.8 (1M context) --- library/Tiger/Model/PasswordHistory.php | 6 +- tests/Integration/MigratorTest.php | 179 ++++++++++++++ tests/Integration/Model/AclModelsTest.php | 142 +++++++++++ tests/Integration/Model/AuthChallengeTest.php | 114 +++++++++ tests/Integration/Model/ConfigTest.php | 105 +++++++++ tests/Integration/Model/LoginTest.php | 125 ++++++++++ tests/Integration/Model/MediaTest.php | 148 ++++++++++++ tests/Integration/Model/MenuTest.php | 188 +++++++++++++++ tests/Integration/Model/ModuleTest.php | 143 +++++++++++ tests/Integration/Model/OptionTest.php | 104 ++++++++ tests/Integration/Model/OrgTest.php | 155 ++++++++++++ tests/Integration/Model/PageTest.php | 186 +++++++++++++++ .../Integration/Model/PasswordHistoryTest.php | 117 +++++++++ tests/Integration/Model/SessionTest.php | 120 ++++++++++ tests/Integration/Model/TableTest.php | 222 ++++++++++++++++++ tests/Integration/Model/TranslationTest.php | 116 +++++++++ .../Integration/Model/UserCredentialTest.php | 168 +++++++++++++ tests/Integration/Model/UserTest.php | 84 +++++++ tests/Integration/Model/VersioningTest.php | 115 +++++++++ .../Integration/Policy/PasswordPolicyTest.php | 105 +++++++++ 20 files changed, 2640 insertions(+), 2 deletions(-) create mode 100644 tests/Integration/MigratorTest.php create mode 100644 tests/Integration/Model/AclModelsTest.php create mode 100644 tests/Integration/Model/AuthChallengeTest.php create mode 100644 tests/Integration/Model/ConfigTest.php create mode 100644 tests/Integration/Model/LoginTest.php create mode 100644 tests/Integration/Model/MediaTest.php create mode 100644 tests/Integration/Model/MenuTest.php create mode 100644 tests/Integration/Model/ModuleTest.php create mode 100644 tests/Integration/Model/OptionTest.php create mode 100644 tests/Integration/Model/OrgTest.php create mode 100644 tests/Integration/Model/PageTest.php create mode 100644 tests/Integration/Model/PasswordHistoryTest.php create mode 100644 tests/Integration/Model/SessionTest.php create mode 100644 tests/Integration/Model/TableTest.php create mode 100644 tests/Integration/Model/TranslationTest.php create mode 100644 tests/Integration/Model/UserCredentialTest.php create mode 100644 tests/Integration/Model/UserTest.php create mode 100644 tests/Integration/Model/VersioningTest.php create mode 100644 tests/Integration/Policy/PasswordPolicyTest.php diff --git a/library/Tiger/Model/PasswordHistory.php b/library/Tiger/Model/PasswordHistory.php index ca4092f..c3070e7 100644 --- a/library/Tiger/Model/PasswordHistory.php +++ b/library/Tiger/Model/PasswordHistory.php @@ -35,9 +35,11 @@ public function archive($userId, $hash) */ public function recentForUser($userId, $limit = 5) { + // The limit MUST live on the Select: Zend_Db_Table_Abstract::fetchAll() ignores its + // $count/$offset args when the first arg is already a Select (it only honors them when + // building the Select itself). Passing `null, $limit` alongside a Select was a silent no-op. return $this->fetchAll( - $this->select()->where('user_id = ?', $userId)->order('created_at DESC'), - null, (int) $limit + $this->select()->where('user_id = ?', $userId)->order('created_at DESC')->limit((int) $limit) ); } diff --git a/tests/Integration/MigratorTest.php b/tests/Integration/MigratorTest.php new file mode 100644 index 0000000..1bb5e67 --- /dev/null +++ b/tests/Integration/MigratorTest.php @@ -0,0 +1,179 @@ +db->query("DROP TABLE IF EXISTS `$t`"); } catch (\Throwable $e) {} + } + foreach (self::FIXTURE_VERSIONS as $v) { + try { $this->db->delete('tiger_migration', $this->db->quoteInto('version = ?', $v)); } catch (\Throwable $e) {} + } + foreach ($this->tmpDirs as $dir) { + foreach (glob($dir . '/*') ?: [] as $f) { @unlink($f); } + @rmdir($dir); + } + parent::tearDown(); + } + + /** Make a temp migrations dir seeded with the given [filename => php-source] fixtures. */ + private function fixtureDir(array $files): string + { + $dir = sys_get_temp_dir() . '/tiger_mig_' . bin2hex(random_bytes(6)); + mkdir($dir, 0777, true); + $this->tmpDirs[] = $dir; + foreach ($files as $name => $src) { + file_put_contents($dir . '/' . $name, $src); + } + return $dir; + } + + /** A migration file body that CREATEs then DROPs a throwaway table. */ + private static function createDropMigration(string $table): string + { + return " [\"CREATE TABLE `$table` (id INT)\"], 'down' => [\"DROP TABLE IF EXISTS `$table`\"]];"; + } + + private function ledgerHas(string $version): bool + { + return (int) $this->db->fetchOne('SELECT COUNT(*) FROM tiger_migration WHERE version = ?', [$version]) > 0; + } + + #[Test] + public function migrate_applies_pending_versions_in_ascending_order_and_records_the_ledger(): void + { + // Deliberately write the higher version FIRST to prove discovery sorts, not filesystem order. + $dir = $this->fixtureDir([ + '9002_create_b.php' => self::createDropMigration('zzz_mig_b'), + '9001_create_a.php' => self::createDropMigration('zzz_mig_a'), + ]); + $migrator = new Tiger_Db_Migrator($this->db, [$dir]); + + $applied = $migrator->migrate(); + + // PHP casts numeric-string array keys to int, so the discovered version map is keyed by int. + $this->assertSame([9001, 9002], array_keys($applied), 'applied in ascending version order'); + $this->assertSame(['create_a', 'create_b'], array_values($applied), 'name parsed from the filename'); + $this->assertTrue($this->tableExists('zzz_mig_a'), 'the up statement ran'); + $this->assertTrue($this->tableExists('zzz_mig_b')); + $this->assertTrue($this->ledgerHas('9001'), 'applied version is recorded in tiger_migration'); + $this->assertTrue($this->ledgerHas('9002')); + } + + #[Test] + public function migrate_is_idempotent_a_second_run_applies_nothing(): void + { + $dir = $this->fixtureDir(['9001_create_a.php' => self::createDropMigration('zzz_mig_a')]); + $migrator = new Tiger_Db_Migrator($this->db, [$dir]); + + $this->assertSame(['9001' => 'create_a'], $migrator->migrate(), 'first run applies it'); + $this->assertSame([], $migrator->migrate(), 'a second run skips the already-applied version'); + } + + #[Test] + public function status_reports_applied_versus_pending(): void + { + $dir = $this->fixtureDir([ + '9001_create_a.php' => self::createDropMigration('zzz_mig_a'), + '9002_create_b.php' => self::createDropMigration('zzz_mig_b'), + ]); + $migrator = new Tiger_Db_Migrator($this->db, [$dir]); + + // Apply only 9001 by hand-recording it, then let status() classify both. + $migrator->migrate(); // applies both + $this->db->delete('tiger_migration', $this->db->quoteInto('version = ?', '9002')); // pretend 9002 pending + + $status = $migrator->status(); + $this->assertTrue($status['9001']['applied'], '9001 is applied'); + $this->assertFalse($status['9002']['applied'], '9002 reads as pending'); + $this->assertSame('create_a', $status['9001']['name']); + } + + #[Test] + public function a_migration_that_fails_midway_is_left_unrecorded_and_stays_pending(): void + { + // 9101 is clean; 9102's SECOND statement is invalid SQL. The runner throws while applying 9102, + // so 9102 must NOT be recorded — leaving it pending to be retried on the next run. + $dir = $this->fixtureDir([ + '9101_create_p1.php' => self::createDropMigration('zzz_mig_p1'), + '9102_partial.php' => " [" + . "\"CREATE TABLE IF NOT EXISTS `zzz_mig_p2` (id INT)\", " + . "\"THIS IS NOT VALID SQL\"" + . "], 'down' => [\"DROP TABLE IF EXISTS `zzz_mig_p2`\"]];", + ]); + $migrator = new Tiger_Db_Migrator($this->db, [$dir]); + + $threw = false; + try { + $migrator->migrate(); + } catch (\Throwable $e) { + $threw = true; // the invalid statement surfaces as an exception + } + $this->assertTrue($threw, 'an invalid statement aborts the run'); + + // The clean earlier migration IS recorded; the failing one is NOT. + $this->assertTrue($this->ledgerHas('9101'), 'the migration before the failure committed + recorded'); + $this->assertFalse($this->ledgerHas('9102'), 'apply-only-after-all-succeed: the failed migration is unrecorded'); + + // A subsequent run still sees it as pending (retried, never silently marked done). + $this->assertFalse($migrator->status()['9102']['applied'], '9102 remains pending after a failed run'); + } + + #[Test] + public function rollback_runs_down_statements_and_deletes_the_ledger_row(): void + { + $dir = $this->fixtureDir(['9201_create_r.php' => self::createDropMigration('zzz_mig_r')]); + $migrator = new Tiger_Db_Migrator($this->db, [$dir]); + + $migrator->migrate(); + $this->assertTrue($this->tableExists('zzz_mig_r'), 'precondition: up created the table'); + $this->assertTrue($this->ledgerHas('9201'), 'precondition: recorded'); + + $rolled = $migrator->rollback(1); + + $this->assertSame(['9201' => 'create_r'], $rolled, 'rollback reports what it reversed'); + $this->assertFalse($this->tableExists('zzz_mig_r'), 'the down statement dropped the table'); + $this->assertFalse($this->ledgerHas('9201'), 'the ledger row is removed so it can be re-applied'); + } +} diff --git a/tests/Integration/Model/AclModelsTest.php b/tests/Integration/Model/AclModelsTest.php new file mode 100644 index 0000000..0da5deb --- /dev/null +++ b/tests/Integration/Model/AclModelsTest.php @@ -0,0 +1,142 @@ +resources = new Tiger_Model_AclResource(); + $this->roles = new Tiger_Model_AclRole(); + $this->rules = new Tiger_Model_AclRule(); + } + + /** Collect one column out of a loader's rowset into a plain sorted array. */ + private function col(iterable $rows, string $key): array + { + $out = []; + foreach ($rows as $row) { + $out[] = $row->$key; + } + sort($out); + return $out; + } + + // ----- acl_resource ------------------------------------------------------------------------- + + #[Test] + public function resource_loader_returns_active_rows_and_excludes_deleted(): void + { + $this->resources->insert(['resource' => 'Billing_Service_Invoice']); + $this->resources->insert(['resource' => 'Reports_Service_Export']); + $doomed = $this->resources->insert(['resource' => 'Legacy_Service_Gone']); + + // Baseline: the tables ship empty (migrations seed no acl_* rows), so the loader returns + // exactly what we seeded — a precise assertion, not a "contains". + $this->assertSame( + ['Billing_Service_Invoice', 'Legacy_Service_Gone', 'Reports_Service_Export'], + $this->col($this->resources->getResourceList(), 'resource'), + 'the active set is exactly the seeded resources' + ); + + // Soft-delete one — it must drop out of the active set (an extra resource must not linger). + $this->resources->softDelete($this->db->quoteInto('acl_resource_id = ?', $doomed)); + $this->assertSame( + ['Billing_Service_Invoice', 'Reports_Service_Export'], + $this->col($this->resources->getResourceList(), 'resource'), + 'a soft-deleted resource is NOT returned by the loader' + ); + } + + // ----- acl_role ----------------------------------------------------------------------------- + + #[Test] + public function role_loader_returns_active_rows_and_excludes_deleted(): void + { + $this->roles->insert(['role' => 'editor', 'parent_role' => 'user']); + $doomed = $this->roles->insert(['role' => 'ghost', 'parent_role' => 'user']); + + $this->assertSame(['editor', 'ghost'], $this->col($this->roles->getRoleList(), 'role')); + + // A soft-deleted role must vanish from the graph the ACL builds — a stale role in the graph + // could carry inherited grants it shouldn't. + $this->roles->softDelete($this->db->quoteInto('acl_role_id = ?', $doomed)); + $this->assertSame( + ['editor'], + $this->col($this->roles->getRoleList(), 'role'), + 'a soft-deleted role is NOT returned by the loader' + ); + } + + // ----- acl_rule (the load-bearing one) ------------------------------------------------------ + + #[Test] + public function rule_loader_returns_active_rows_and_excludes_deleted(): void + { + // An allow grant and a deny grant — both must be visible while live… + $allow = $this->rules->insert(['role' => 'editor', 'resource' => 'Billing_Service_Invoice', 'privilege' => 'view', 'permission' => 'allow']); + $deny = $this->rules->insert(['role' => 'editor', 'resource' => 'Billing_Service_Invoice', 'privilege' => 'delete', 'permission' => 'deny']); + + $loaded = []; + foreach ($this->rules->getRuleList() as $r) { + $loaded[$r->acl_rule_id] = $r->permission; + } + $this->assertCount(2, $loaded, 'both live rules load'); + $this->assertSame('allow', $loaded[$allow]); + $this->assertSame('deny', $loaded[$deny]); + } + + #[Test] + public function a_soft_deleted_allow_rule_stops_granting(): void + { + // The security invariant stated as a scenario: revoke an allow by soft-deleting it, and it + // must no longer be in the set the ACL loads — otherwise a revoked grant keeps granting. + $grant = $this->rules->insert(['role' => 'editor', 'resource' => 'Billing_Service_Invoice', 'privilege' => 'view', 'permission' => 'allow']); + $this->assertCount(1, iterator_to_array($this->rules->getRuleList()), 'the grant is live'); + + $this->rules->softDelete($this->db->quoteInto('acl_rule_id = ?', $grant)); + + $this->assertCount( + 0, + iterator_to_array($this->rules->getRuleList()), + 'a soft-deleted allow rule is NOT loaded — a revoked grant cannot keep granting' + ); + } + + #[Test] + public function a_soft_deleted_deny_rule_is_dropped_so_it_cannot_keep_blocking(): void + { + // Symmetry: the DB tier wins last, so a stray deny would override an ini allow. Once + // soft-deleted it must fall out of the set, or it keeps flipping decisions to deny. + $deny = $this->rules->insert(['role' => 'editor', 'resource' => 'Reports_Service_Export', 'privilege' => 'run', 'permission' => 'deny']); + $this->rules->softDelete($this->db->quoteInto('acl_rule_id = ?', $deny)); + + $this->assertSame([], iterator_to_array($this->rules->getRuleList()), 'a dropped deny rule is gone from the loader'); + } +} diff --git a/tests/Integration/Model/AuthChallengeTest.php b/tests/Integration/Model/AuthChallengeTest.php new file mode 100644 index 0000000..a072f1d --- /dev/null +++ b/tests/Integration/Model/AuthChallengeTest.php @@ -0,0 +1,114 @@ + ['security' => ['pepper' => base64_encode(str_repeat("\x41", 32))]], + ], true)); + $this->challenge = new Tiger_Model_AuthChallenge(); + } + + /** A real user to hang challenges on (user_id FK is ON DELETE CASCADE, nullable on the table). */ + private function makeUser(): string + { + return (new Tiger_Model_User())->insert(['email' => 'chal-' . bin2hex(random_bytes(8)) . '@example.test']); + } + + #[Test] + public function a_correct_code_within_ttl_redeems_and_returns_the_row(): void + { + $user = $this->makeUser(); + $id = $this->challenge->issue($user, 'password_reset', '123456', 600); + + $row = $this->challenge->redeem($id, '123456'); + $this->assertNotNull($row, 'a correct, unexpired, first-time code redeems'); + $this->assertSame($id, $row->challenge_id); + $this->assertNotNull($this->challenge->findById($id)->consumed_at, 'redeem stamps consumed_at'); + } + + #[Test] + public function an_expired_challenge_does_not_redeem(): void + { + $user = $this->makeUser(); + // Negative TTL puts expires_at in the past at issue time — no clock trickery needed. + $id = $this->challenge->issue($user, 'sms_otp', '654321', -10); + + $this->assertNull($this->challenge->redeem($id, '654321'), 'past-TTL challenge is dead on arrival'); + $this->assertNull($this->challenge->findById($id)->consumed_at, 'an expired redeem never consumes the row'); + } + + #[Test] + public function a_redeemed_challenge_cannot_be_redeemed_again(): void + { + $user = $this->makeUser(); + $id = $this->challenge->issue($user, 'magic_link', 'abcdef', 600); + + $this->assertNotNull($this->challenge->redeem($id, 'abcdef'), 'first redeem succeeds'); + $this->assertNull($this->challenge->redeem($id, 'abcdef'), 'single-use: the second redeem is refused even with the right code'); + } + + #[Test] + public function too_many_wrong_attempts_lock_the_challenge_out(): void + { + $user = $this->makeUser(); + $id = $this->challenge->issue($user, 'sms_otp', '111111', 600); + + // MAX_ATTEMPTS (5) wrong tries — each returns null and costs one attempt. + for ($i = 0; $i < Tiger_Model_AuthChallenge::MAX_ATTEMPTS; $i++) { + $this->assertNull($this->challenge->redeem($id, '000000'), "wrong attempt #$i fails"); + } + $this->assertSame(Tiger_Model_AuthChallenge::MAX_ATTEMPTS, (int) $this->challenge->findById($id)->attempts); + + // Even the CORRECT code is now refused — the lock trips before the code is checked. + $this->assertNull($this->challenge->redeem($id, '111111'), 'a locked-out challenge refuses the correct code too'); + } + + #[Test] + public function the_stored_code_is_hashed_not_plaintext_and_a_wrong_code_fails(): void + { + $user = $this->makeUser(); + $plain = '246810'; + $id = $this->challenge->issue($user, 'email_verify', $plain, 600); + + $stored = (string) $this->challenge->findById($id)->code_hash; + $this->assertNotSame($plain, $stored, 'the plaintext code is never persisted'); + $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $stored, 'code_hash is a 64-hex keyed digest'); + $this->assertSame($stored, Tiger_Security::hashCode($plain, 'challenge'), 'and it is the peppered hash of the code'); + + $this->assertNull($this->challenge->redeem($id, '999999'), 'a wrong code never redeems'); + $this->assertNotNull($this->challenge->redeem($id, $plain), 'the right code still redeems (wrong attempt did not consume it)'); + } +} diff --git a/tests/Integration/Model/ConfigTest.php b/tests/Integration/Model/ConfigTest.php new file mode 100644 index 0000000..4c8001f --- /dev/null +++ b/tests/Integration/Model/ConfigTest.php @@ -0,0 +1,105 @@ +config = new Tiger_Model_Config(); + } + + #[Test] + public function a_global_and_an_org_value_for_the_same_key_do_not_collide(): void + { + $orgId = Tiger_Uuid::v7(); + $key = 'tiger.skin'; + + $this->config->set(Tiger_Model_Config::SCOPE_GLOBAL, '', $key, 'jaguar'); + $this->config->set(Tiger_Model_Config::SCOPE_ORG, $orgId, $key, 'cheetah'); + + // Each scope resolves to its OWN value — the per-org theming guarantee. + $this->assertSame('jaguar', $this->config->get(Tiger_Model_Config::SCOPE_GLOBAL, '', $key)); + $this->assertSame('cheetah', $this->config->get(Tiger_Model_Config::SCOPE_ORG, $orgId, $key)); + } + + #[Test] + public function two_orgs_with_the_same_key_are_isolated_from_each_other(): void + { + $orgA = Tiger_Uuid::v7(); + $orgB = Tiger_Uuid::v7(); + + $this->config->set(Tiger_Model_Config::SCOPE_ORG, $orgA, 'site.name', 'Acme'); + $this->config->set(Tiger_Model_Config::SCOPE_ORG, $orgB, 'site.name', 'Beta'); + + $this->assertSame('Acme', $this->config->get(Tiger_Model_Config::SCOPE_ORG, $orgA, 'site.name')); + $this->assertSame('Beta', $this->config->get(Tiger_Model_Config::SCOPE_ORG, $orgB, 'site.name')); + // A tenant with no row of its own gets null, never a neighbour's value. + $this->assertNull($this->config->get(Tiger_Model_Config::SCOPE_ORG, Tiger_Uuid::v7(), 'site.name')); + } + + #[Test] + public function set_upserts_in_place_rather_than_inserting_a_duplicate(): void + { + $key = 'tiger.session.ttl'; + + $firstId = $this->config->set(Tiger_Model_Config::SCOPE_GLOBAL, '', $key, '3600'); + $secondId = $this->config->set(Tiger_Model_Config::SCOPE_GLOBAL, '', $key, '7200'); + + $this->assertSame($firstId, $secondId, 'a second set() updates the SAME row (upsert)'); + $this->assertSame('7200', $this->config->get(Tiger_Model_Config::SCOPE_GLOBAL, '', $key), 'last write wins'); + + // Exactly one active row for the (scope, scope_id, key) triple. + $rows = $this->config->fetchAll( + $this->config->activeSelect() + ->where('scope = ?', Tiger_Model_Config::SCOPE_GLOBAL) + ->where('scope_id = ?', '') + ->where('config_key = ?', $key) + ); + $this->assertCount(1, $rows, 'no duplicate row is created'); + } + + #[Test] + public function an_unknown_key_resolves_to_null(): void + { + $this->assertNull( + $this->config->get(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.nonexistent.' . Tiger_Uuid::v4()), + 'an unset key returns null' + ); + } + + #[Test] + public function getForScope_returns_only_that_scopes_rows(): void + { + $orgId = Tiger_Uuid::v7(); + $this->config->set(Tiger_Model_Config::SCOPE_GLOBAL, '', 'g.one', '1'); + $this->config->set(Tiger_Model_Config::SCOPE_ORG, $orgId, 'o.one', '1'); + $this->config->set(Tiger_Model_Config::SCOPE_ORG, $orgId, 'o.two', '2'); + + $orgRows = $this->config->getForScope(Tiger_Model_Config::SCOPE_ORG, $orgId); + $keys = []; + foreach ($orgRows as $r) { $keys[] = $r->config_key; } + sort($keys); + $this->assertSame(['o.one', 'o.two'], $keys, 'only this org scope\'s rows, not the global one'); + } +} diff --git a/tests/Integration/Model/LoginTest.php b/tests/Integration/Model/LoginTest.php new file mode 100644 index 0000000..26d533a --- /dev/null +++ b/tests/Integration/Model/LoginTest.php @@ -0,0 +1,125 @@ +login = new Tiger_Model_Login(); + } + + /** Record an attempt and force its created_at to `now - $ageSeconds` (0 = leave as now). */ + private function attempt(array $data, int $ageSeconds = 0): string + { + $id = $this->login->record($data); + if ($ageSeconds > 0) { + $this->db->update( + 'login', + ['created_at' => date('Y-m-d H:i:s', time() - $ageSeconds)], + $this->db->quoteInto('login_id = ?', $id) + ); + } + return $id; + } + + #[Test] + public function failure_count_sums_only_non_success_inside_the_window_for_that_identifier(): void + { + $id = 'victim@example.test'; + + // Three that MUST count: two failures + one locked, all recent. + $this->attempt(['identifier' => $id, 'result' => Tiger_Model_Login::RESULT_FAILURE]); + $this->attempt(['identifier' => $id, 'result' => Tiger_Model_Login::RESULT_FAILURE]); + $this->attempt(['identifier' => $id, 'result' => Tiger_Model_Login::RESULT_LOCKED]); + // A SUCCESS must NOT count (a good login is not a brute-force signal). + $this->attempt(['identifier' => $id, 'result' => Tiger_Model_Login::RESULT_SUCCESS]); + // A failure for a DIFFERENT identifier must NOT bleed into this count. + $this->attempt(['identifier' => 'someone-else@example.test', 'result' => Tiger_Model_Login::RESULT_FAILURE]); + + $this->assertSame( + 3, + $this->login->recentFailuresForIdentifier($id, 900), + 'exactly the recent failure+locked attempts for this identifier — success and other users excluded' + ); + } + + #[Test] + public function failures_older_than_the_window_are_not_counted(): void + { + $id = 'window@example.test'; + + // One fresh failure (inside), one aged 20 minutes (outside a 15-min window). + $this->attempt(['identifier' => $id, 'result' => Tiger_Model_Login::RESULT_FAILURE]); + $this->attempt(['identifier' => $id, 'result' => Tiger_Model_Login::RESULT_FAILURE], 1200); + + $this->assertSame(1, $this->login->recentFailuresForIdentifier($id, 900), 'only the in-window failure counts'); + // Widen the window past the aged row and both count — proves it was the window, not the row. + $this->assertSame(2, $this->login->recentFailuresForIdentifier($id, 1800), 'a wider window re-includes the aged failure'); + } + + #[Test] + public function ip_failure_count_windows_and_filters_the_same_way(): void + { + $ip = '203.0.113.7'; + + $this->attempt(['ip_address' => $ip, 'identifier' => 'a@example.test', 'result' => Tiger_Model_Login::RESULT_FAILURE]); + $this->attempt(['ip_address' => $ip, 'identifier' => 'b@example.test', 'result' => Tiger_Model_Login::RESULT_LOCKED]); + $this->attempt(['ip_address' => $ip, 'identifier' => 'c@example.test', 'result' => Tiger_Model_Login::RESULT_SUCCESS]); + $this->attempt(['ip_address' => $ip, 'identifier' => 'd@example.test', 'result' => Tiger_Model_Login::RESULT_FAILURE], 1200); + // A failure from another IP must not count toward this one. + $this->attempt(['ip_address' => '198.51.100.9', 'identifier' => 'e@example.test', 'result' => Tiger_Model_Login::RESULT_FAILURE]); + + // In-window non-success from this IP across several identifiers = 2 (the distributed signal). + $this->assertSame(2, $this->login->recentFailuresFromIp($ip, 900), 'IP count spans identifiers but excludes success, the aged row, and other IPs'); + } + + #[Test] + public function no_matching_attempts_counts_zero(): void + { + // A never-seen identifier / IP must be a clean 0, not a null or an error. + $this->assertSame(0, $this->login->recentFailuresForIdentifier('nobody@example.test', 900)); + $this->assertSame(0, $this->login->recentFailuresFromIp('192.0.2.1', 900)); + } + + #[Test] + public function record_persists_the_attempt_and_recent_history_is_newest_first(): void + { + $userId = Tiger_Uuid::v7(); + + $older = $this->attempt(['user_id' => $userId, 'identifier' => 'hist@example.test', 'result' => Tiger_Model_Login::RESULT_FAILURE], 60); + $newer = $this->attempt(['user_id' => $userId, 'identifier' => 'hist@example.test', 'result' => Tiger_Model_Login::RESULT_SUCCESS]); + + $rows = $this->login->recentForUser($userId, 10); + $this->assertCount(2, $rows, 'both attempts are recorded against the user'); + + $ordered = []; + foreach ($rows as $r) { + $ordered[] = $r->login_id; + } + $this->assertSame([$newer, $older], $ordered, 'recent history is newest-first'); + } +} diff --git a/tests/Integration/Model/MediaTest.php b/tests/Integration/Model/MediaTest.php new file mode 100644 index 0000000..985fa02 --- /dev/null +++ b/tests/Integration/Model/MediaTest.php @@ -0,0 +1,148 @@ +`), so private bytes can't be reached without passing the access gate. + * A regression here (a private file resolving to `/_media/…`) would be a real data leak. + * + * classify() gates uploads against the configured `media.allow.*` allowlists: known image/document + * extensions classify + are allowed; a disallowed/unknown extension → KIND_OTHER, not allowed. + * + * Both paths read the `media` config, so we register a Zend_Config with a filesystem disk + allowlists + * for the duration of the test (and reset the storage memo, since it caches adapters per disk name). + */ +#[CoversClass(Tiger_Model_Media::class)] +final class MediaTest extends IntegrationTestCase +{ + private Tiger_Model_Media $media; + private ?Zend_Config $priorConfig = null; + + protected function setUp(): void + { + parent::setUp(); + $this->media = new Tiger_Model_Media(); + + $this->priorConfig = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + Zend_Registry::set('Zend_Config', new Zend_Config([ + 'media' => [ + 'default_disk' => 'local', + 'disks' => [ + 'local' => [ + 'adapter' => 'filesystem', + 'public_url' => '/_media', + 'public_root' => 'public/_media', + 'private_root' => 'storage/media', + ], + ], + 'allow' => [ + 'image' => 'jpg,jpeg,png,gif,webp', + 'document' => 'pdf,doc,docx,txt', + 'video' => 'mp4,webm', + 'audio' => 'mp3', + 'archive' => 'zip', + ], + ], + ])); + Tiger_Media_Storage::reset(); + } + + protected function tearDown(): void + { + // Restore the prior registry state so we don't leak config into sibling test files. + if ($this->priorConfig !== null) { + Zend_Registry::set('Zend_Config', $this->priorConfig); + } else { + Zend_Registry::set('Zend_Config', new Zend_Config([])); + } + Tiger_Media_Storage::reset(); + parent::tearDown(); + } + + private function insertMedia(array $overrides): string + { + return $this->media->insert(array_merge([ + 'org_id' => '', + 'disk' => 'local', + 'visibility' => Tiger_Model_Media::VISIBILITY_PUBLIC, + 'kind' => Tiger_Model_Media::KIND_IMAGE, + 'storage_key' => 'images/example.jpg', + 'filename' => 'example.jpg', + ], $overrides)); + } + + #[Test] + public function a_public_file_resolves_to_a_direct_docroot_url(): void + { + $id = $this->insertMedia(['visibility' => Tiger_Model_Media::VISIBILITY_PUBLIC, 'storage_key' => 'images/logo.png']); + $row = $this->media->findById($id)->toArray(); + + $url = $this->media->url($row); + $this->assertSame('/_media/images/logo.png', $url, 'a public object gets a direct docroot URL'); + } + + #[Test] + public function a_private_file_never_yields_a_public_direct_url(): void + { + $id = $this->insertMedia(['visibility' => Tiger_Model_Media::VISIBILITY_PRIVATE, 'storage_key' => 'docs/secret.pdf', 'kind' => Tiger_Model_Media::KIND_PDF]); + $row = $this->media->findById($id)->toArray(); + + $url = $this->media->url($row); + + // The access-scoping invariant: it must be the ACL-checked streamer route, keyed by the id — + // and it must NOT be a public direct URL that would bypass the gate. + $this->assertSame('/media/file/serve/id/' . rawurlencode($id), $url); + $this->assertStringStartsWith('/media/file/serve/', $url, 'private goes through the streamer route'); + $this->assertStringNotContainsString('/_media/', $url, 'private must never expose the public docroot path'); + $this->assertStringNotContainsString('secret.pdf', $url, 'the private storage key is not leaked in the URL'); + } + + #[Test] + public function classify_allows_known_image_and_document_extensions(): void + { + $img = Tiger_Model_Media::classify('jpg'); + $this->assertSame(Tiger_Model_Media::KIND_IMAGE, $img['kind']); + $this->assertTrue($img['allowed']); + + // Case-insensitive and a leading dot is tolerated. + $this->assertSame(Tiger_Model_Media::KIND_IMAGE, Tiger_Model_Media::classify('PNG')['kind']); + $this->assertTrue(Tiger_Model_Media::classify('.gif')['allowed']); + + // pdf is split out of documents (its own kind) but still allowed via the document allowlist. + $pdf = Tiger_Model_Media::classify('pdf'); + $this->assertSame(Tiger_Model_Media::KIND_PDF, $pdf['kind']); + $this->assertTrue($pdf['allowed']); + + $doc = Tiger_Model_Media::classify('docx'); + $this->assertSame(Tiger_Model_Media::KIND_DOCUMENT, $doc['kind']); + $this->assertTrue($doc['allowed']); + + $zip = Tiger_Model_Media::classify('zip'); + $this->assertSame(Tiger_Model_Media::KIND_ARCHIVE, $zip['kind']); + $this->assertTrue($zip['allowed']); + } + + #[Test] + public function classify_rejects_a_disallowed_or_unknown_extension_as_other(): void + { + foreach (['exe', 'php', 'sh', 'unknownext', ''] as $ext) { + $c = Tiger_Model_Media::classify($ext); + $this->assertSame(Tiger_Model_Media::KIND_OTHER, $c['kind'], "'{$ext}' is not on any allowlist → other"); + $this->assertFalse($c['allowed'], "'{$ext}' must not be allowed"); + } + } +} diff --git a/tests/Integration/Model/MenuTest.php b/tests/Integration/Model/MenuTest.php new file mode 100644 index 0000000..cad9a11 --- /dev/null +++ b/tests/Integration/Model/MenuTest.php @@ -0,0 +1,188 @@ +menu = new Tiger_Model_Menu(); + } + + /** + * reorder() opens its OWN transaction (correct in production), which can't nest inside the harness's + * per-test transaction (Zend_Db/PDO has no nesting). So a reorder test commits its setup rows to + * leave the harness transaction, lets reorder manage its own, and relies on tearDown() to scrub the + * table (this isolated test DB's `menu` is owned entirely by this suite). + */ + private function commitSetup(): void + { + $this->db->commit(); + } + + protected function tearDown(): void + { + // Remove any rows a reorder test committed outside the harness transaction. For a normal + // (still-in-transaction) test this runs inside that transaction and is undone by the rollback. + try { + $this->db->query('DELETE FROM menu'); + } catch (\Throwable $e) { + // ignore + } + parent::tearDown(); + } + + private function insertItem(array $overrides): string + { + return $this->menu->insert(array_merge([ + 'org_id' => '', + 'menu_key' => 'primary', + 'parent_id' => null, + 'sort_order' => 0, + 'label' => 'Item', + 'status' => Tiger_Model_Menu::STATUS_PUBLISHED, + ], $overrides)); + } + + #[Test] + public function items_are_grouped_by_menu_key(): void + { + $this->insertItem(['menu_key' => 'primary', 'label' => 'Home', 'sort_order' => 0]); + $this->insertItem(['menu_key' => 'primary', 'label' => 'About', 'sort_order' => 1]); + $this->insertItem(['menu_key' => 'footer', 'label' => 'Legal', 'sort_order' => 0]); + + $primary = $this->menu->flat('primary', ''); + $footer = $this->menu->flat('footer', ''); + + $this->assertCount(2, $primary, 'the primary menu has exactly its two items'); + $this->assertCount(1, $footer, 'the footer menu is a separate group'); + $labels = array_map(static fn ($r) => $r['label'], $primary); + $this->assertSame(['Home', 'About'], $labels, 'ordered by sort_order within the menu'); + } + + #[Test] + public function a_tenant_menu_replaces_the_global_menu_whole(): void + { + $orgA = Tiger_Uuid::v7(); + $orgB = Tiger_Uuid::v7(); + + // Global 'primary' with two items… + $this->insertItem(['menu_key' => 'primary', 'org_id' => '', 'label' => 'G-Home', 'sort_order' => 0]); + $this->insertItem(['menu_key' => 'primary', 'org_id' => '', 'label' => 'G-About', 'sort_order' => 1]); + // …and a tenant-A override with a SINGLE, different item. + $this->insertItem(['menu_key' => 'primary', 'org_id' => $orgA, 'label' => 'A-Dashboard', 'sort_order' => 0]); + + // Tenant A sees ONLY its own menu — not a merge of global + tenant. + $seenByA = $this->menu->flat('primary', $orgA); + $this->assertCount(1, $seenByA, 'the tenant menu replaces global whole (no item merge)'); + $this->assertSame('A-Dashboard', $seenByA[0]['label']); + + // Tenant B has no rows for the key → falls back to the global menu. + $seenByB = $this->menu->flat('primary', $orgB); + $this->assertCount(2, $seenByB, 'a tenant with no own menu gets global'); + $this->assertSame(['G-Home', 'G-About'], array_map(static fn ($r) => $r['label'], $seenByB)); + } + + #[Test] + public function reorder_only_touches_items_owned_by_the_given_menu_scope(): void + { + // Two menus in the global scope; 'other' is foreign to the reorder call below. + $home = $this->insertItem(['menu_key' => 'primary', 'label' => 'Home', 'sort_order' => 0]); + $about = $this->insertItem(['menu_key' => 'primary', 'label' => 'About', 'sort_order' => 1]); + $alien = $this->insertItem(['menu_key' => 'other', 'label' => 'Alien', 'sort_order' => 7]); + $this->commitSetup(); + + // The client tries to sneak the foreign 'other' item into a 'primary' reorder. + $n = $this->menu->reorder([ + ['menu_id' => $home, 'parent_id' => null, 'sort_order' => 5], + ['menu_id' => $alien, 'parent_id' => null, 'sort_order' => 0], // must be ignored (not owned) + ], 'primary', ''); + + $this->assertSame(1, $n, 'only the one owned item was updated; the foreign item was skipped'); + $this->assertSame(5, (int) $this->menu->findById($home)->sort_order, 'the owned item moved'); + $this->assertSame(7, (int) $this->menu->findById($alien)->sort_order, 'the foreign menu\'s item is untouched'); + $this->assertSame(1, (int) $this->menu->findById($about)->sort_order, 'an unmentioned item keeps its order'); + } + + #[Test] + public function reorder_cannot_pull_in_another_orgs_item(): void + { + $orgA = Tiger_Uuid::v7(); + $mine = $this->insertItem(['menu_key' => 'primary', 'org_id' => '', 'label' => 'Mine', 'sort_order' => 0]); + $foreign = $this->insertItem(['menu_key' => 'primary', 'org_id' => $orgA, 'label' => 'Foreign', 'sort_order' => 3]); + $this->commitSetup(); + + // Reordering the GLOBAL 'primary' must not be able to move org A's same-keyed item. + $n = $this->menu->reorder([ + ['menu_id' => $mine, 'parent_id' => null, 'sort_order' => 9], + ['menu_id' => $foreign, 'parent_id' => null, 'sort_order' => 0], + ], 'primary', ''); + + $this->assertSame(1, $n); + $this->assertSame(3, (int) $this->menu->findById($foreign)->sort_order, 'a cross-org item is never touched'); + } + + #[Test] + public function reorder_refuses_a_parent_that_is_not_a_sibling_in_the_menu(): void + { + $home = $this->insertItem(['menu_key' => 'primary', 'label' => 'Home', 'sort_order' => 0]); + $child = $this->insertItem(['menu_key' => 'primary', 'label' => 'Child', 'sort_order' => 1]); + $alien = $this->insertItem(['menu_key' => 'other', 'label' => 'Alien', 'sort_order' => 0]); + $this->commitSetup(); + + // Try to re-parent an owned item under a FOREIGN item — the guard must null the bogus parent. + $this->menu->reorder([ + ['menu_id' => $child, 'parent_id' => $alien, 'sort_order' => 0], + ], 'primary', ''); + $this->assertNull($this->menu->findById($child)->parent_id, 'a parent outside the menu is refused (nulled)'); + + // A legitimate re-parent under a real sibling sticks. + $this->menu->reorder([ + ['menu_id' => $child, 'parent_id' => $home, 'sort_order' => 0], + ], 'primary', ''); + $this->assertSame($home, $this->menu->findById($child)->parent_id, 'a real in-menu parent is accepted'); + } + + #[Test] + public function the_tree_assembles_parent_child_via_self_reference(): void + { + $parent = $this->insertItem(['menu_key' => 'primary', 'label' => 'Products', 'sort_order' => 0]); + $childA = $this->insertItem(['menu_key' => 'primary', 'label' => 'Widgets', 'parent_id' => $parent, 'sort_order' => 0]); + $childB = $this->insertItem(['menu_key' => 'primary', 'label' => 'Gadgets', 'parent_id' => $parent, 'sort_order' => 1]); + $this->insertItem(['menu_key' => 'primary', 'label' => 'Contact', 'sort_order' => 1]); // a second top-level + + $tree = $this->menu->tree('primary'); + + $this->assertCount(2, $tree, 'two top-level nodes (Products, Contact)'); + $this->assertSame('Products', $tree[0]['label']); + $this->assertCount(2, $tree[0]['children'], 'Products has its two children nested under it'); + $this->assertSame(['Widgets', 'Gadgets'], array_map(static fn ($r) => $r['label'], $tree[0]['children'])); + $this->assertSame($parent, $tree[0]['children'][0]['parent_id'], 'the child self-references its parent'); + $this->assertSame([], $tree[1]['children'], 'the sibling top-level has no children'); + } +} diff --git a/tests/Integration/Model/ModuleTest.php b/tests/Integration/Model/ModuleTest.php new file mode 100644 index 0000000..1ff63ee --- /dev/null +++ b/tests/Integration/Model/ModuleTest.php @@ -0,0 +1,143 @@ +module = new Tiger_Model_Module(); + } + + private function slugs(): array + { + $s = $this->module->inactiveSlugs(); + sort($s); + return $s; + } + + #[Test] + public function inactive_slugs_returns_only_deactivated_rows_never_active_or_rowless(): void + { + // active row → NOT in the gate list; inactive row → IN it; and a module with NO row is + // "active by absence" and equally absent from the list. + $this->module->insert(['slug' => 'blog', 'active' => 1, 'source' => Tiger_Model_Module::SOURCE_DISCOVERED]); + $this->module->insert(['slug' => 'forum', 'active' => 0, 'source' => Tiger_Model_Module::SOURCE_DISCOVERED]); + $this->module->insert(['slug' => 'wiki', 'active' => 0, 'source' => Tiger_Model_Module::SOURCE_DISCOVERED]); + // (no row for 'shop' — active by absence) + + $this->assertSame( + ['forum', 'wiki'], + $this->slugs(), + 'the gate returns exactly the active=0 slugs — not the active one, not the row-less one' + ); + } + + #[Test] + public function set_active_creates_a_discovered_row_the_first_time_then_upserts(): void + { + // First toggle of a never-seen module MINTS a row (discovered provenance) … + $id = $this->module->setActive('gallery', false); + $row = $this->module->bySlug('gallery'); + $this->assertNotNull($row, 'setActive creates a row for a discovered module'); + $this->assertSame($id, $row->module_id, 'setActive returns the row id it created'); + $this->assertSame(0, (int) $row->active); + $this->assertSame('inactive', $row->status, 'status tracks the active flag'); + $this->assertSame(Tiger_Model_Module::SOURCE_DISCOVERED, $row->source); + $this->assertContains('gallery', $this->module->inactiveSlugs(), 'a freshly-deactivated module is in the gate'); + + // … a second toggle UPSERTS the SAME row (no duplicate) and flips the flags back. + $id2 = $this->module->setActive('gallery', true); + $this->assertSame($id, $id2, 'setActive upserts — same module_id, no new row'); + $row2 = $this->module->bySlug('gallery'); + $this->assertSame(1, (int) $row2->active); + $this->assertSame('active', $row2->status); + $this->assertNotContains('gallery', $this->module->inactiveSlugs(), 're-activated → out of the gate'); + $this->assertCount(1, $this->module->fetchAll($this->module->select()->where('slug = ?', 'gallery')), 'exactly one row survives the upsert'); + } + + #[Test] + public function install_forces_active_and_records_provenance(): void + { + $id = $this->module->install('shop', [ + 'name' => 'Tiger Shop', + 'version' => '0.1.0-beta', + 'repository' => 'WebTigers/TigerShop', + 'ref' => 'v0.1.0-beta', + 'source' => Tiger_Model_Module::SOURCE_URL, + ]); + + $row = $this->module->bySlug('shop'); + $this->assertNotNull($row); + $this->assertSame($id, $row->module_id); + $this->assertSame(1, (int) $row->active, 'install always forces active=1'); + $this->assertSame('active', $row->status); + $this->assertSame('Tiger Shop', $row->name); + $this->assertSame('0.1.0-beta', $row->version); + $this->assertSame('WebTigers/TigerShop', $row->repository); + $this->assertSame('v0.1.0-beta', $row->ref); + $this->assertSame(Tiger_Model_Module::SOURCE_URL, $row->source); + $this->assertNotContains('shop', $this->module->inactiveSlugs(), 'an installed module is active, not gated'); + } + + #[Test] + public function install_reactivates_and_updates_a_previously_deactivated_module(): void + { + // A module the admin had turned off, then (re)installs, must come back ACTIVE — install + // forces active=1 on the existing row rather than minting a duplicate. + $first = $this->module->setActive('forum', false); + $this->assertContains('forum', $this->module->inactiveSlugs()); + + $second = $this->module->install('forum', ['name' => 'Forum', 'version' => '1.2.3']); + $this->assertSame($first, $second, 'install upserts the same row'); + $row = $this->module->bySlug('forum'); + $this->assertSame(1, (int) $row->active, 'install re-activates a deactivated module'); + $this->assertSame('1.2.3', $row->version, 'provenance is refreshed on the existing row'); + $this->assertNotContains('forum', $this->module->inactiveSlugs()); + } + + #[Test] + public function uninstall_hard_deletes_the_row(): void + { + // The module table is NOT soft-deleted: uninstall removes the row entirely, so the module + // reverts to "active by absence" (its files being gone is the installer's concern). + $this->module->install('temp', ['name' => 'Temp']); + $this->assertNotNull($this->module->bySlug('temp')); + + $deleted = $this->module->uninstall('temp'); + $this->assertSame(1, $deleted, 'uninstall reports one row removed'); + $this->assertNull($this->module->bySlug('temp'), 'the row is hard-deleted, not soft-deleted'); + } + + #[Test] + public function uninstalling_a_deactivated_module_removes_it_from_the_gate(): void + { + // A subtle safety check: if uninstall only soft-deleted, an active=0 row would linger and + // keep the (now-absent) module permanently gated. Hard-delete makes it vanish from the gate. + $this->module->setActive('doomed', false); + $this->assertContains('doomed', $this->module->inactiveSlugs()); + + $this->module->uninstall('doomed'); + $this->assertNotContains('doomed', $this->module->inactiveSlugs(), 'an uninstalled module is not left gated'); + } +} diff --git a/tests/Integration/Model/OptionTest.php b/tests/Integration/Model/OptionTest.php new file mode 100644 index 0000000..6d2d10e --- /dev/null +++ b/tests/Integration/Model/OptionTest.php @@ -0,0 +1,104 @@ +option = new Tiger_Model_Option(); + } + + #[Test] + public function set_then_get_round_trips_a_value(): void + { + $user = Tiger_Uuid::v7(); + $this->option->set(Tiger_Model_Option::SCOPE_USER, $user, 'ui.sidebar', 'collapsed'); + + $this->assertSame('collapsed', $this->option->get(Tiger_Model_Option::SCOPE_USER, $user, 'ui.sidebar')); + } + + #[Test] + public function the_same_key_is_isolated_across_scope_ids(): void + { + $userA = Tiger_Uuid::v7(); + $userB = Tiger_Uuid::v7(); + + $this->option->set(Tiger_Model_Option::SCOPE_USER, $userA, 'dash.layout', 'A-layout'); + $this->option->set(Tiger_Model_Option::SCOPE_USER, $userB, 'dash.layout', 'B-layout'); + + $this->assertSame('A-layout', $this->option->get(Tiger_Model_Option::SCOPE_USER, $userA, 'dash.layout')); + $this->assertSame('B-layout', $this->option->get(Tiger_Model_Option::SCOPE_USER, $userB, 'dash.layout')); + // A user who never set it gets null, never a neighbour's layout. + $this->assertNull($this->option->get(Tiger_Model_Option::SCOPE_USER, Tiger_Uuid::v7(), 'dash.layout')); + } + + #[Test] + public function the_same_key_is_isolated_across_scope_types(): void + { + $id = Tiger_Uuid::v7(); // deliberately reuse the id across two DIFFERENT scope TYPES + $this->option->set(Tiger_Model_Option::SCOPE_USER, $id, 'pref', 'user-value'); + $this->option->set(Tiger_Model_Option::SCOPE_ORG, $id, 'pref', 'org-value'); + + $this->assertSame('user-value', $this->option->get(Tiger_Model_Option::SCOPE_USER, $id, 'pref')); + $this->assertSame('org-value', $this->option->get(Tiger_Model_Option::SCOPE_ORG, $id, 'pref')); + } + + #[Test] + public function set_upserts_in_place(): void + { + $user = Tiger_Uuid::v7(); + $first = $this->option->set(Tiger_Model_Option::SCOPE_USER, $user, 'wizard.step', '1'); + $second = $this->option->set(Tiger_Model_Option::SCOPE_USER, $user, 'wizard.step', '4'); + + $this->assertSame($first, $second, 'a second set() updates the same row'); + $this->assertSame('4', $this->option->get(Tiger_Model_Option::SCOPE_USER, $user, 'wizard.step')); + } + + #[Test] + public function json_helpers_round_trip_structured_values(): void + { + $user = Tiger_Uuid::v7(); + $layout = ['cols' => 3, 'widgets' => ['clock', 'stats']]; + + $this->option->setJson(Tiger_Model_Option::SCOPE_USER, $user, 'dash', $layout); + + $this->assertSame($layout, $this->option->getJson(Tiger_Model_Option::SCOPE_USER, $user, 'dash')); + // getJson returns the default for a missing key. + $this->assertSame(['fallback'], $this->option->getJson(Tiger_Model_Option::SCOPE_USER, $user, 'missing', ['fallback'])); + } + + #[Test] + public function forget_soft_deletes_so_the_key_reads_null_again(): void + { + $user = Tiger_Uuid::v7(); + $this->option->set(Tiger_Model_Option::SCOPE_USER, $user, 'notice.dismissed', '1'); + $this->assertSame('1', $this->option->get(Tiger_Model_Option::SCOPE_USER, $user, 'notice.dismissed')); + + $this->option->forget(Tiger_Model_Option::SCOPE_USER, $user, 'notice.dismissed'); + + $this->assertNull( + $this->option->get(Tiger_Model_Option::SCOPE_USER, $user, 'notice.dismissed'), + 'a forgotten option is excluded from the active read' + ); + } +} diff --git a/tests/Integration/Model/OrgTest.php b/tests/Integration/Model/OrgTest.php new file mode 100644 index 0000000..92a897e --- /dev/null +++ b/tests/Integration/Model/OrgTest.php @@ -0,0 +1,155 @@ +org = new Tiger_Model_Org(); + + // Force the founding-org branch: no configured tiger.site.org_id. + $this->priorConfig = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + Zend_Registry::set('Zend_Config', new Zend_Config([])); + $this->resetSiteOrgMemo(); + } + + protected function tearDown(): void + { + $this->resetSiteOrgMemo(); + if ($this->priorConfig !== null) { + Zend_Registry::set('Zend_Config', $this->priorConfig); + } else { + Zend_Registry::set('Zend_Config', new Zend_Config([])); + } + parent::tearDown(); + } + + /** Clear the process-static site-org cache so a test starts from a clean memo. */ + private function resetSiteOrgMemo(): void + { + // ReflectionProperty is accessible without setAccessible() on PHP 8.1+. + (new ReflectionProperty(Tiger_Model_Org::class, '_siteOrgId'))->setValue(null, null); + } + + private function makeOrg(string $name, ?string $createdAt = null, ?string $parent = null): string + { + $data = [ + 'name' => $name, + 'slug' => strtolower($name) . '-' . substr(Tiger_Uuid::v7(), 0, 8), + 'parent_org_id' => $parent, + ]; + if ($createdAt !== null) { + $data['created_at'] = $createdAt; // base only stamps created_at when it's empty + } + return $this->org->insert($data); + } + + #[Test] + public function site_org_id_resolves_to_the_founding_oldest_org(): void + { + // Insert out of chronological order; the FOUNDING org is the oldest by created_at, not by insert order. + $newer = $this->makeOrg('Newer', '2026-06-01 00:00:00'); + $founding = $this->makeOrg('Founder', '2026-01-01 00:00:00'); + $middle = $this->makeOrg('Middle', '2026-03-01 00:00:00'); + + $this->assertSame($founding, Tiger_Model_Org::siteOrgId(), 'the oldest org is the site/founding org'); + } + + #[Test] + public function site_org_id_is_memoized_across_calls(): void + { + $first = $this->makeOrg('First', '2026-01-01 00:00:00'); + $this->assertSame($first, Tiger_Model_Org::siteOrgId(), 'resolves the founding org'); + + // A later insert of an EVEN OLDER org must not change the already-cached answer this request. + $this->makeOrg('EvenOlder', '2020-01-01 00:00:00'); + $this->assertSame($first, Tiger_Model_Org::siteOrgId(), 'the memo holds — no re-query mid-request'); + + // …and after a reset it re-resolves and now picks up the older founding org. + $this->resetSiteOrgMemo(); + $olderRows = $this->org->fetchAll($this->org->activeSelect()->where('name = ?', 'EvenOlder')); + $this->assertSame($olderRows->current()->org_id, Tiger_Model_Org::siteOrgId(), 'a fresh request re-resolves the oldest'); + } + + #[Test] + public function site_org_id_is_blank_when_no_org_exists(): void + { + // Nothing inserted in this transaction → no founding org → ''. + $this->assertSame('', Tiger_Model_Org::siteOrgId(), 'a pre-install install has no site org'); + } + + #[Test] + public function slug_taken_reflects_live_uniqueness(): void + { + $id = $this->makeOrgWithSlug('acme'); + + $this->assertTrue($this->org->slugTaken('acme'), 'an in-use slug is taken'); + $this->assertFalse($this->org->slugTaken('vacant'), 'an unused slug is free'); + $this->assertFalse($this->org->slugTaken('acme', $id), 'excluding the owner itself, the slug is free (edit case)'); + + // A soft-deleted org frees its slug for the resolver (slugTaken builds on activeSelect). + $this->org->softDelete(['org_id = ?' => $id]); + $this->assertFalse($this->org->slugTaken('acme'), 'a soft-deleted org no longer holds its slug'); + } + + private function makeOrgWithSlug(string $slug): string + { + return $this->org->insert(['name' => ucfirst($slug), 'slug' => $slug]); + } + + #[Test] + public function parent_org_id_models_a_hierarchy(): void + { + $parent = $this->makeOrg('Enterprise'); + $childA = $this->makeOrg('DeptA', null, $parent); + $childB = $this->makeOrg('DeptB', null, $parent); + $unrelated = $this->makeOrg('Other'); + + // A child resolves its parent off its own row. + $this->assertSame($parent, $this->org->findById($childA)->parent_org_id); + $this->assertNull($this->org->findById($parent)->parent_org_id, 'a root org has no parent'); + + // children() lists exactly the direct children, no one else. + $childIds = []; + foreach ($this->org->children($parent) as $row) { + $childIds[] = $row->org_id; + } + sort($childIds); + $expected = [$childA, $childB]; + sort($expected); + $this->assertSame($expected, $childIds, 'exactly the two departments are children of the enterprise'); + } + + #[Test] + public function find_by_slug_resolves_the_route_facing_identifier(): void + { + $id = $this->org->insert(['name' => 'Routed', 'slug' => 'routed-co']); + $this->assertSame($id, $this->org->findBySlug('routed-co')->org_id); + $this->assertNull($this->org->findBySlug('no-such-slug')); + } +} diff --git a/tests/Integration/Model/PageTest.php b/tests/Integration/Model/PageTest.php new file mode 100644 index 0000000..f59bd1b --- /dev/null +++ b/tests/Integration/Model/PageTest.php @@ -0,0 +1,186 @@ +page = new Tiger_Model_Page(); + } + + /** A DATETIME string offset from now by a day-scale delta (safe past/future well beyond any tz skew). */ + private function at(string $modify): string + { + return date('Y-m-d H:i:s', strtotime($modify)); + } + + private function insertPage(array $overrides): string + { + return $this->page->insert(array_merge([ + 'org_id' => '', + 'type' => Tiger_Model_Page::TYPE_PAGE, + 'locale' => 'en', + 'format' => Tiger_Model_Page::FORMAT_HTML, + 'status' => Tiger_Model_Page::STATUS_PUBLISHED, + ], $overrides)); + } + + #[Test] + public function a_tenant_page_overrides_the_global_page_for_the_same_slug(): void + { + $orgA = Tiger_Uuid::v7(); + $orgB = Tiger_Uuid::v7(); + + $globalId = $this->insertPage(['org_id' => '', 'slug' => 'about', 'title' => 'Global About']); + $tenantId = $this->insertPage(['org_id' => $orgA, 'slug' => 'about', 'title' => 'Acme About']); + + // The owning tenant sees ITS row (org wins over the global '' via ORDER BY org_id DESC). + $seenByA = $this->page->resolveBySlug('about', 'en', $orgA); + $this->assertNotNull($seenByA); + $this->assertSame($tenantId, $seenByA->page_id, 'the tenant gets its own override'); + $this->assertSame($orgA, $seenByA->org_id); + + // A DIFFERENT tenant has no own row → it falls back to the shared global page, never Acme's. + $seenByB = $this->page->resolveBySlug('about', 'en', $orgB); + $this->assertNotNull($seenByB); + $this->assertSame($globalId, $seenByB->page_id, 'a foreign tenant never sees another tenant\'s page'); + $this->assertSame('', $seenByB->org_id); + } + + #[Test] + public function an_unpublished_or_future_scheduled_page_is_not_served(): void + { + $org = Tiger_Uuid::v7(); + + $draft = $this->insertPage(['org_id' => $org, 'slug' => 'draft', 'status' => Tiger_Model_Page::STATUS_DRAFT]); + $this->assertNull($this->page->resolveBySlug('draft', 'en', $org), 'a draft is never served'); + + // Published but scheduled for the future → not yet live. + $future = $this->insertPage(['org_id' => $org, 'slug' => 'future', 'published_at' => $this->at('+1 day')]); + $this->assertNull($this->page->resolveBySlug('future', 'en', $org), 'a future published_at is scheduled, not live'); + + // Published, schedule already arrived → live. + $past = $this->insertPage(['org_id' => $org, 'slug' => 'past', 'published_at' => $this->at('-1 day')]); + $livePast = $this->page->resolveBySlug('past', 'en', $org); + $this->assertNotNull($livePast, 'a past published_at is live'); + $this->assertSame($past, $livePast->page_id); + + // Published with NULL schedule → live immediately. + $now = $this->insertPage(['org_id' => $org, 'slug' => 'now', 'published_at' => null]); + $liveNow = $this->page->resolveBySlug('now', 'en', $org); + $this->assertNotNull($liveNow, 'a NULL published_at is immediately live'); + $this->assertSame($now, $liveNow->page_id); + } + + #[Test] + public function archived_and_soft_deleted_pages_are_excluded_from_the_resolver(): void + { + $org = Tiger_Uuid::v7(); + + $archived = $this->insertPage(['org_id' => $org, 'slug' => 'archived', 'status' => Tiger_Model_Page::STATUS_ARCHIVED]); + $this->assertNull($this->page->resolveBySlug('archived', 'en', $org), 'an archived page is not served'); + + $live = $this->insertPage(['org_id' => $org, 'slug' => 'gone', 'title' => 'Live']); + $this->assertNotNull($this->page->resolveBySlug('gone', 'en', $org), 'sanity: it resolves while live'); + + $this->page->softDelete(['page_id = ?' => $live]); + $this->assertNull($this->page->resolveBySlug('gone', 'en', $org), 'a soft-deleted page drops out of the resolver'); + } + + #[Test] + public function the_type_filter_distinguishes_page_layout_and_partial(): void + { + $org = Tiger_Uuid::v7(); + + // Three rows sharing one slug but each a different rendering primitive. + $pageId = $this->insertPage(['org_id' => $org, 'slug' => 'shared', 'type' => Tiger_Model_Page::TYPE_PAGE, 'page_key' => null]); + $layoutId = $this->insertPage(['org_id' => $org, 'slug' => null, 'page_key' => 'shared-layout', 'type' => Tiger_Model_Page::TYPE_LAYOUT]); + $partialId = $this->insertPage(['org_id' => $org, 'slug' => null, 'page_key' => 'shared-partial', 'type' => Tiger_Model_Page::TYPE_PARTIAL]); + + // Root slug dispatch asks for a real page and must get the page, not a layout/partial. + $resolved = $this->page->resolveBySlug('shared', 'en', $org, Tiger_Model_Page::TYPE_PAGE); + $this->assertNotNull($resolved); + $this->assertSame($pageId, $resolved->page_id); + $this->assertSame(Tiger_Model_Page::TYPE_PAGE, $resolved->type); + + // Restricting to the wrong type finds nothing at that slug. + $this->assertNull($this->page->resolveBySlug('shared', 'en', $org, Tiger_Model_Page::TYPE_LAYOUT)); + + // The layout/partial are fetchable by their stable handle (not publish-gated infrastructure). + $layout = $this->page->fetchByKey('shared-layout', 'en', $org, Tiger_Model_Page::TYPE_LAYOUT); + $this->assertNotNull($layout); + $this->assertSame($layoutId, $layout->page_id); + $partial = $this->page->fetchByKey('shared-partial', 'en', $org, Tiger_Model_Page::TYPE_PARTIAL); + $this->assertSame($partialId, $partial->page_id); + } + + #[Test] + public function the_body_and_format_round_trip_intact_without_executing_phtml(): void + { + $org = Tiger_Uuid::v7(); + + // A phtml body that WOULD do damage if the store ever evaluated it. It must not — this is a + // data gateway; we assert exact byte-for-byte storage, proving no rendering happens here. + $phtmlBody = 'Hello'; + $mdBody = "# Heading\n\nSome **markdown** with a [shortcode]."; + $htmlBody = '

Plain html body & entity.

'; + + $phtmlId = $this->insertPage(['org_id' => $org, 'slug' => 'code', 'format' => Tiger_Model_Page::FORMAT_PHTML, 'body' => $phtmlBody]); + $mdId = $this->insertPage(['org_id' => $org, 'slug' => 'md', 'format' => Tiger_Model_Page::FORMAT_MARKDOWN, 'body' => $mdBody]); + $htmlId = $this->insertPage(['org_id' => $org, 'slug' => 'html', 'format' => Tiger_Model_Page::FORMAT_HTML, 'body' => $htmlBody]); + + $phtml = $this->page->findById($phtmlId); + $this->assertSame(Tiger_Model_Page::FORMAT_PHTML, $phtml->format); + $this->assertSame($phtmlBody, $phtml->body, 'phtml source is stored verbatim, never executed'); + $this->assertStringNotContainsString('EXECUTED-2', $phtml->body, 'the code must not have run'); + + $md = $this->page->findById($mdId); + $this->assertSame(Tiger_Model_Page::FORMAT_MARKDOWN, $md->format); + $this->assertSame($mdBody, $md->body); + + $html = $this->page->findById($htmlId); + $this->assertSame(Tiger_Model_Page::FORMAT_HTML, $html->format); + $this->assertSame($htmlBody, $html->body); + } + + #[Test] + public function the_resolver_matches_on_locale(): void + { + $org = Tiger_Uuid::v7(); + // Same logical page, one row per language, sharing a page_key. + $en = $this->insertPage(['org_id' => $org, 'slug' => 'welcome', 'locale' => 'en', 'page_key' => 'welcome']); + $es = $this->insertPage(['org_id' => $org, 'slug' => 'welcome', 'locale' => 'es', 'page_key' => 'welcome']); + + $this->assertSame($en, $this->page->resolveBySlug('welcome', 'en', $org)->page_id); + $this->assertSame($es, $this->page->resolveBySlug('welcome', 'es', $org)->page_id); + $this->assertNull($this->page->resolveBySlug('welcome', 'fr', $org), 'an unmatched locale resolves nothing'); + } +} diff --git a/tests/Integration/Model/PasswordHistoryTest.php b/tests/Integration/Model/PasswordHistoryTest.php new file mode 100644 index 0000000..b4ffdb8 --- /dev/null +++ b/tests/Integration/Model/PasswordHistoryTest.php @@ -0,0 +1,117 @@ +history = new Tiger_Model_PasswordHistory(); + } + + private function makeUser(): string + { + return (new Tiger_Model_User())->insert(['email' => 'hist-' . bin2hex(random_bytes(8)) . '@example.test']); + } + + /** Archive a row with an explicit created_at so ordering is unambiguous (secret doubles as a marker). */ + private function archiveAt(string $userId, string $marker, int $ageSeconds): void + { + $this->history->insert([ + 'user_id' => $userId, + 'secret' => $marker, + 'created_at' => date('Y-m-d H:i:s', time() - $ageSeconds), + ]); + } + + #[Test] + public function recent_for_user_returns_newest_first(): void + { + $user = $this->makeUser(); + $this->archiveAt($user, 'oldest', 300); + $this->archiveAt($user, 'middle', 200); + $this->archiveAt($user, 'newest', 100); + + $markers = []; + foreach ($this->history->recentForUser($user, 5) as $row) { + $markers[] = (string) $row->secret; + } + $this->assertSame(['newest', 'middle', 'oldest'], $markers, 'newest retired hash comes first'); + } + + #[Test] + public function recent_for_user_scopes_to_the_user(): void + { + $user = $this->makeUser(); + $other = $this->makeUser(); + $this->archiveAt($user, 'u-new', 100); + $this->archiveAt($user, 'u-old', 200); + $this->archiveAt($other, 'other', 150); + + $secrets = []; + foreach ($this->history->recentForUser($user, 50) as $row) { + $this->assertSame($user, $row->user_id, 'only this user\'s history is returned'); + $secrets[] = (string) $row->secret; + } + $this->assertSame(['u-new', 'u-old'], $secrets, 'newest-first, and never another user\'s row'); + } + + /** + * recentForUser() honors its $limit. (Regression guard for a fixed bug: the limit used to be a + * silent no-op — passed as fetchAll()'s $count alongside a Select, which Zend_Db_Table_Abstract + * ignores when the first arg is already a Select, so it returned ALL retained rows regardless. + * Now the limit lives ON the Select — see Model/PasswordHistory.php. This pins that it takes.) + */ + #[Test] + public function recent_for_user_honors_its_limit(): void + { + $user = $this->makeUser(); + $this->archiveAt($user, 'r1', 300); + $this->archiveAt($user, 'r2', 200); + $this->archiveAt($user, 'r3', 100); + + // Fixed: the $limit now lives on the Select, so a limit of 1 returns exactly the newest row, + // and 2 returns the newest two — the configured `history` count is honored (see method docblock). + $this->assertCount(1, $this->history->recentForUser($user, 1), 'limit=1 → newest only'); + $this->assertSame('r3', $this->history->recentForUser($user, 1)->current()->secret); + $this->assertCount(2, $this->history->recentForUser($user, 2), 'limit=2 → newest two'); + $this->assertCount(3, $this->history->recentForUser($user, 5), 'limit > rows → all'); + } + + #[Test] + public function prune_keeps_only_the_newest_n(): void + { + $user = $this->makeUser(); + // Seven rows, oldest→newest, spaced so age order is stable. + foreach (['h1' => 700, 'h2' => 600, 'h3' => 500, 'h4' => 400, 'h5' => 300, 'h6' => 200, 'h7' => 100] as $marker => $age) { + $this->archiveAt($user, $marker, $age); + } + + $this->history->prune($user, 3); + + $kept = []; + foreach ($this->history->recentForUser($user, 50) as $row) { + $kept[] = (string) $row->secret; + } + $this->assertSame(['h7', 'h6', 'h5'], $kept, 'prune retains exactly the newest 3, dropping the rest'); + } +} diff --git a/tests/Integration/Model/SessionTest.php b/tests/Integration/Model/SessionTest.php new file mode 100644 index 0000000..6386974 --- /dev/null +++ b/tests/Integration/Model/SessionTest.php @@ -0,0 +1,120 @@ + lifetime` — the expiry math. A not-yet-expired + * session must SURVIVE a GC pass (reaping it early = a random logout), and an expired one must be + * removed (leaving it = a session that outlives its TTL). + * - `deleteByUserId()` force-logs-out EVERY session a user holds (admin revoke / "sign out + * everywhere") and touches no other user's rows. + * + * Session extends Zend_Db_Table_Abstract directly (session_id is the PHP session id, not a UUID), + * so tests seed rows through the adapter with explicit `modified`/`lifetime` unix ints. + */ +#[CoversClass(Tiger_Model_Session::class)] +final class SessionTest extends IntegrationTestCase +{ + private Tiger_Model_Session $session; + + protected function setUp(): void + { + parent::setUp(); + $this->session = new Tiger_Model_Session(); + } + + /** Seed a session row with a chosen age; `modified = now - $age`, `lifetime = $lifetime`. */ + private function seed(string $sid, int $age, int $lifetime, ?string $userId = null): void + { + $this->db->insert('session', [ + 'session_id' => $sid, + 'modified' => time() - $age, + 'lifetime' => $lifetime, + 'data' => 'x|s:0:"";', + 'user_id' => $userId, + ]); + } + + private function exists(string $sid): bool + { + return (int) $this->db->fetchOne('SELECT COUNT(*) FROM session WHERE session_id = ?', [$sid]) === 1; + } + + #[Test] + public function gc_reaps_only_expired_sessions_and_spares_live_ones(): void + { + // Expired: idle 4000s under a 3600s lifetime → (now-modified) > lifetime → reaped. + $this->seed('sess-expired', 4000, 3600); + // Live: just touched → 0 idle → survives comfortably. + $this->seed('sess-fresh', 0, 3600); + // Live near the edge: idle 100s under a 3600s lifetime → still inside TTL → survives. + $this->seed('sess-recent', 100, 3600); + + $removed = $this->session->gc(); + + $this->assertSame(1, $removed, 'gc removes exactly the one expired session'); + $this->assertFalse($this->exists('sess-expired'), 'the expired session is reaped'); + $this->assertTrue($this->exists('sess-fresh'), 'a fresh session survives GC'); + $this->assertTrue($this->exists('sess-recent'), 'a not-yet-expired session survives GC (no early logout)'); + } + + #[Test] + public function gc_respects_each_rows_own_lifetime(): void + { + // Same age, different lifetimes: the short-lived one is expired, the long-lived one is not — + // proving GC compares per-row lifetime, not a global constant. + $this->seed('sess-short', 500, 300); // idle 500 > 300 lifetime → expired + $this->seed('sess-long', 500, 86400); // idle 500 < 86400 lifetime → alive + + $removed = $this->session->gc(); + + $this->assertSame(1, $removed); + $this->assertFalse($this->exists('sess-short'), 'the short-lifetime session expired'); + $this->assertTrue($this->exists('sess-long'), 'the long-lifetime session is still alive'); + } + + #[Test] + public function delete_by_user_id_force_logs_out_all_of_that_users_sessions_only(): void + { + $victim = Tiger_Uuid::v7(); + $bystander = Tiger_Uuid::v7(); + + // Two devices for the victim, one for an unrelated user. + $this->seed('victim-a', 10, 3600, $victim); + $this->seed('victim-b', 20, 3600, $victim); + $this->seed('bystander-a', 30, 3600, $bystander); + + $deleted = $this->session->deleteByUserId($victim); + + $this->assertSame(2, $deleted, 'both of the victim\'s sessions are killed'); + $this->assertFalse($this->exists('victim-a')); + $this->assertFalse($this->exists('victim-b')); + $this->assertTrue($this->exists('bystander-a'), 'another user\'s session is untouched'); + } + + #[Test] + public function get_by_user_id_lists_only_that_users_sessions_newest_first(): void + { + $user = Tiger_Uuid::v7(); + $this->seed('u-older', 300, 3600, $user); // modified further in the past + $this->seed('u-newer', 5, 3600, $user); // modified most recently + $this->seed('other', 5, 3600, Tiger_Uuid::v7()); + + $ids = []; + foreach ($this->session->getByUserId($user) as $row) { + $ids[] = $row->session_id; + } + + $this->assertSame(['u-newer', 'u-older'], $ids, 'exactly this user\'s sessions, ordered by modified DESC'); + } +} diff --git a/tests/Integration/Model/TableTest.php b/tests/Integration/Model/TableTest.php new file mode 100644 index 0000000..349bae6 --- /dev/null +++ b/tests/Integration/Model/TableTest.php @@ -0,0 +1,222 @@ +media = new Tiger_Model_Media(); + } + + /** Insert a minimal media row (only storage_key is required by the schema); return its id. */ + private function insertMedia(string $key = 'k'): string + { + return $this->media->insert(['storage_key' => $key . '-' . substr(Tiger_Uuid::v7(), 0, 8)]); + } + + /** The RFC canonical version nibble sits at string index 14 (…-Vxxx-…). */ + private static function versionChar(string $uuid): string + { + return $uuid[14]; + } + + #[Test] + public function insert_mints_a_valid_v7_uuid_primary_key(): void + { + $id = $this->insertMedia(); + + $this->assertTrue(Tiger_Uuid::isValid($id), 'insert() must return a canonical UUID'); + $this->assertSame(36, strlen($id), 'canonical UUID is 36 chars'); + $this->assertSame('7', self::versionChar($id), 'the default PK is a v7 (time-ordered) UUID'); + + // The returned id is the actual stored PK (not a meaningless lastInsertId()). + $row = $this->db->fetchRow('SELECT * FROM media WHERE media_id = ?', [$id]); + $this->assertNotEmpty($row, 'the minted id is the row that was written'); + } + + #[Test] + public function v7_primary_keys_are_time_ordered_across_inserts(): void + { + // v7 embeds a ms timestamp in its leading 48 bits, so two ids minted in sequence are ordered + // to the millisecond. (Tiger_Uuid documents that it does NOT guarantee strict intra-ms + // monotonicity, so the honest invariant is >= on the embedded time, not a raw string compare.) + $first = $this->insertMedia('a'); + $second = $this->insertMedia('b'); + + $this->assertGreaterThanOrEqual( + Tiger_Uuid::timeOf($first), + Tiger_Uuid::timeOf($second), + 'a later v7 insert carries an equal-or-greater embedded timestamp' + ); + // The embedded time is a real, recent creation time (sanity: within a minute of now). + $this->assertEqualsWithDelta(time(), Tiger_Uuid::timeOf($first), 60, 'v7 time reflects creation'); + } + + #[Test] + public function a_model_declaring_uuid_version_4_mints_an_opaque_v4(): void + { + // Tiger_Model_AuthChallenge sets $_uuidVersion = 4 so its ids (which appear in reset/magic URLs) + // never leak creation time. A direct insert avoids the Tiger_Security hashing path. + $ac = new Tiger_Model_AuthChallenge(); + $id = $ac->insert([ + 'type' => 'email_verify', + 'code_hash' => 'not-a-real-hash', + 'expires_at' => date('Y-m-d H:i:s', time() + 600), + ]); + + $this->assertTrue(Tiger_Uuid::isValid($id)); + $this->assertSame('4', self::versionChar($id), 'a $_uuidVersion=4 model mints a v4 (opaque) PK'); + } + + #[Test] + public function actor_stamp_credits_created_by_and_updated_by(): void + { + $actor = Tiger_Uuid::v7(); + Tiger_Model_Table::setActor($actor); + + $id = $this->insertMedia(); + $row = $this->db->fetchRow('SELECT * FROM media WHERE media_id = ?', [$id]); + + $this->assertSame($actor, $row['created_by'], 'created_by is stamped from the current actor'); + $this->assertSame($actor, $row['updated_by'], 'updated_by is stamped on insert too'); + } + + #[Test] + public function a_null_actor_leaves_the_stamps_null(): void + { + // The base resets the actor to null each test (system/CLI/genesis context). + $this->assertNull(Tiger_Model_Table::actor(), 'harness starts each test with no actor'); + + $id = $this->insertMedia(); + $row = $this->db->fetchRow('SELECT * FROM media WHERE media_id = ?', [$id]); + + $this->assertNull($row['created_by'], 'no actor => created_by is NULL (system/genesis)'); + $this->assertNull($row['updated_by'], 'no actor => updated_by is NULL'); + } + + #[Test] + public function org_stamp_credits_org_id_when_an_org_is_set(): void + { + $org = Tiger_Uuid::v7(); + Tiger_Model_Table::setOrg($org); + + $id = $this->insertMedia(); + $row = $this->db->fetchRow('SELECT * FROM media WHERE media_id = ?', [$id]); + + $this->assertSame($org, $row['org_id'], 'org_id is stamped from the current org (tenant ownership)'); + } + + #[Test] + public function no_org_leaves_org_id_at_the_global_default(): void + { + // Harness resets setOrg('') — an explicit '' (not null) means "don't stamp", so the column + // keeps its '' default: the platform/global scope. + $id = $this->insertMedia(); + $row = $this->db->fetchRow('SELECT * FROM media WHERE media_id = ?', [$id]); + + $this->assertSame('', $row['org_id'], "no active org => org_id stays '' (global scope)"); + } + + #[Test] + public function an_explicit_org_id_always_wins_over_the_current_org(): void + { + Tiger_Model_Table::setOrg(Tiger_Uuid::v7()); + $explicit = Tiger_Uuid::v7(); + + $id = $this->media->insert(['storage_key' => 'x-' . substr($explicit, 0, 8), 'org_id' => $explicit]); + $row = $this->db->fetchRow('SELECT * FROM media WHERE media_id = ?', [$id]); + + $this->assertSame($explicit, $row['org_id'], 'a passed org_id overrides the ambient one'); + } + + #[Test] + public function insert_sets_created_at_and_updated_at(): void + { + $id = $this->insertMedia(); + $row = $this->db->fetchRow('SELECT * FROM media WHERE media_id = ?', [$id]); + + $this->assertNotNull($row['created_at'], 'created_at is stamped on insert'); + $this->assertNotNull($row['updated_at'], 'updated_at is stamped on insert'); + $this->assertSame(0, (int) $row['deleted'], 'a fresh row is not soft-deleted'); + } + + #[Test] + public function update_refreshes_updated_at_and_updated_by_without_touching_created_by(): void + { + $creator = Tiger_Uuid::v7(); + Tiger_Model_Table::setActor($creator); + $id = $this->insertMedia(); + + // A DIFFERENT actor performs the update. + $editor = Tiger_Uuid::v7(); + Tiger_Model_Table::setActor($editor); + $this->media->update(['title' => 'edited'], $this->db->quoteInto('media_id = ?', $id)); + + $row = $this->db->fetchRow('SELECT * FROM media WHERE media_id = ?', [$id]); + $this->assertSame('edited', $row['title']); + $this->assertSame($creator, $row['created_by'], 'created_by is immutable — the original author'); + $this->assertSame($editor, $row['updated_by'], 'updated_by tracks the last editor'); + $this->assertNotNull($row['updated_at']); + // DATETIME is second-precision, so assert monotonic-non-decreasing, never strict >. + $this->assertGreaterThanOrEqual($row['created_at'], $row['updated_at'], 'updated_at does not go backward'); + } + + #[Test] + public function soft_delete_hides_the_row_from_active_finders_yet_keeps_it_physically(): void + { + $id = $this->insertMedia(); + + $this->media->softDelete($this->db->quoteInto('media_id = ?', $id)); + + // The tenant-safety invariant: gone from every default read... + $this->assertNull($this->media->findById($id), 'findById() excludes a soft-deleted row'); + $active = $this->media->fetchAll($this->media->activeSelect()->where('media_id = ?', $id)); + $this->assertCount(0, $active, 'activeSelect() excludes a soft-deleted row'); + + // ...but the row is still there (soft delete, not a physical delete), flag flipped. + $raw = $this->db->fetchRow('SELECT * FROM media WHERE media_id = ?', [$id]); + $this->assertNotEmpty($raw, 'the row is retained physically'); + $this->assertSame(1, (int) $raw['deleted'], 'deleted flag is set to 1'); + $this->assertNotNull($this->media->findById($id, true), 'includeDeleted=true still finds it'); + } + + #[Test] + public function restore_clears_the_soft_delete(): void + { + $id = $this->insertMedia(); + $this->media->softDelete($this->db->quoteInto('media_id = ?', $id)); + $this->assertNull($this->media->findById($id), 'precondition: hidden while deleted'); + + $this->media->restore($this->db->quoteInto('media_id = ?', $id)); + + $this->assertNotNull($this->media->findById($id), 'restore() makes the row visible again'); + $raw = $this->db->fetchRow('SELECT deleted FROM media WHERE media_id = ?', [$id]); + $this->assertSame(0, (int) $raw['deleted'], 'restore() clears the deleted flag'); + } +} diff --git a/tests/Integration/Model/TranslationTest.php b/tests/Integration/Model/TranslationTest.php new file mode 100644 index 0000000..68fdfc2 --- /dev/null +++ b/tests/Integration/Model/TranslationTest.php @@ -0,0 +1,116 @@ +t = new Tiger_Model_Translation(); + } + + #[Test] + public function set_then_get_returns_the_override_value(): void + { + $this->t->set('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'core.api.success', 'All good.'); + $this->assertSame( + 'All good.', + $this->t->get('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'core.api.success'), + 'an override row wins — get returns exactly what was set' + ); + } + + #[Test] + public function get_returns_null_for_an_unknown_key(): void + { + $this->t->set('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'core.known', 'known'); + $this->assertNull( + $this->t->get('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'core.does.not.exist'), + 'no row → null, so resolution falls through to the file base' + ); + } + + #[Test] + public function set_upserts_in_place_rather_than_stacking_duplicate_rows(): void + { + $firstId = $this->t->set('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'app.greeting', 'Hello'); + $secondId = $this->t->set('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'app.greeting', 'Howdy'); + + $this->assertSame($firstId, $secondId, 'the second set updates the same row (same id)'); + $this->assertSame('Howdy', $this->t->get('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'app.greeting'), 'later write wins'); + + $count = (int) $this->db->fetchOne( + 'SELECT COUNT(*) FROM translation WHERE locale = ? AND scope = ? AND scope_id = ? AND translation_key = ?', + ['en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'app.greeting'] + ); + $this->assertSame(1, $count, 'upsert keeps exactly one row for the key'); + } + + #[Test] + public function scope_and_locale_are_hard_boundaries(): void + { + $orgId = Tiger_Uuid::v7(); + + // Same key, three different (locale, scope, scope_id) coordinates — three independent values. + $this->t->set('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'core.brand', 'Global EN'); + $this->t->set('es', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'core.brand', 'Global ES'); + $this->t->set('en', Tiger_Model_Translation::SCOPE_ORG, $orgId, 'core.brand', 'Org EN'); + + $this->assertSame('Global EN', $this->t->get('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'core.brand')); + $this->assertSame('Global ES', $this->t->get('es', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'core.brand'), 'a different locale is a different value'); + $this->assertSame('Org EN', $this->t->get('en', Tiger_Model_Translation::SCOPE_ORG, $orgId, 'core.brand'), 'the org scope has its own override'); + + // A different org has NO override for the key — scope isolation, not a leak of the global one. + $this->assertNull($this->t->get('en', Tiger_Model_Translation::SCOPE_ORG, Tiger_Uuid::v7(), 'core.brand'), 'another org gets null, not the global value'); + } + + #[Test] + public function get_for_locale_returns_only_the_scoped_map(): void + { + $orgId = Tiger_Uuid::v7(); + $this->t->set('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'a.one', 'One'); + $this->t->set('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'a.two', 'Two'); + $this->t->set('es', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'a.one', 'Uno'); // other locale + $this->t->set('en', Tiger_Model_Translation::SCOPE_ORG, $orgId, 'a.one', 'OrgOne'); // other scope + + $map = $this->t->getForLocale('en', Tiger_Model_Translation::SCOPE_GLOBAL, ''); + + $this->assertSame(['a.one' => 'One', 'a.two' => 'Two'], $map, 'exactly the en/global overrides as a key=>value map — no other locale or scope'); + } + + #[Test] + public function a_soft_deleted_override_no_longer_wins(): void + { + // get() builds on activeSelect(), so a soft-deleted override drops out and resolution falls + // back to the file base (here: null in the model tier). + $id = $this->t->set('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'core.temp', 'temporary'); + $this->assertSame('temporary', $this->t->get('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'core.temp')); + + $this->t->softDelete($this->db->quoteInto('translation_id = ?', $id)); + $this->assertNull( + $this->t->get('en', Tiger_Model_Translation::SCOPE_GLOBAL, '', 'core.temp'), + 'a soft-deleted override is no longer returned' + ); + } +} diff --git a/tests/Integration/Model/UserCredentialTest.php b/tests/Integration/Model/UserCredentialTest.php new file mode 100644 index 0000000..adb41f5 --- /dev/null +++ b/tests/Integration/Model/UserCredentialTest.php @@ -0,0 +1,168 @@ +useCrypto(self::PEP_A); // default: pepper ON, key present + $this->cred = new Tiger_Model_UserCredential(); + } + + /** Seed the process-global crypto key + (optional) pepper the models read from the registry. */ + 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)); + } + + private function makeUser(): string + { + return (new Tiger_Model_User())->insert(['email' => 'cred-' . bin2hex(random_bytes(8)) . '@example.test']); + } + + #[Test] + public function verify_password_accepts_the_right_one_and_rejects_the_wrong_one(): void + { + $user = $this->makeUser(); + $this->cred->setPassword($user, 'correct horse battery'); + + $this->assertTrue($this->cred->verifyPassword($user, 'correct horse battery'), 'the right password verifies'); + $this->assertFalse($this->cred->verifyPassword($user, 'Tr0ub4dour&3'), 'a wrong password fails'); + $this->assertFalse($this->cred->verifyPassword($this->makeUser(), 'anything'), 'no credential => false'); + } + + #[Test] + public function a_no_pepper_hash_still_verifies_and_is_rehashed_with_the_pepper(): void + { + // Store the password with NO pepper configured — a legacy, un-peppered bcrypt hash. + $this->useCrypto(null); + $user = $this->makeUser(); + $this->cred->setPassword($user, 'legacySecret99'); + $legacyHash = (string) $this->cred->passwordCredential($user)->secret; + $this->assertTrue(password_verify('legacySecret99', $legacyHash), 'baseline: legacy hash is over the raw password'); + + // Now turn the pepper ON and verify: it must still pass (legacy fallback) AND re-hash. + $this->useCrypto(self::PEP_A); + $this->assertTrue($this->cred->verifyPassword($user, 'legacySecret99'), 'legacy hash still verifies after adding a pepper'); + + $migrated = (string) $this->cred->passwordCredential($user)->secret; + $this->assertNotSame($legacyHash, $migrated, 'the hash was transparently re-hashed on verify'); + $this->assertFalse(password_verify('legacySecret99', $migrated), 'the new hash is no longer over the raw password'); + $this->assertTrue( + password_verify(Tiger_Security::prehashPassword('legacySecret99'), $migrated), + 'the new hash is over the PEPPERED prehash' + ); + $this->assertTrue($this->cred->verifyPassword($user, 'legacySecret99'), 'and it keeps verifying under the pepper'); + } + + #[Test] + public function a_personal_access_token_verifies_once_and_a_revoked_one_never_does(): void + { + $user = $this->makeUser(); + $minted = $this->cred->createToken($user); + + $this->assertMatchesRegularExpression('/^tgr_[a-f0-9]{12}_[a-f0-9]{48}$/', $minted['token']); + $this->assertSame($user, $this->cred->verifyToken($minted['token']), 'a valid token resolves its owner'); + $this->assertNull($this->cred->verifyToken('tgr_deadbeef0000_' . str_repeat('0', 48)), 'an unknown token fails'); + $this->assertNull($this->cred->verifyToken('not-a-token'), 'a malformed token fails'); + + $this->cred->revokeToken($user, $minted['credential_id']); + $this->assertNull($this->cred->verifyToken($minted['token']), 'a revoked (soft-deleted) token never verifies again'); + } + + #[Test] + public function a_recovery_code_redeems_once_then_burns(): void + { + $user = $this->makeUser(); + // replaceTotp stores recovery secrets exactly as codeMatches(...,'recovery') will hash them. + $hashes = [ + Tiger_Security::hashCode('aaaa1111', 'recovery'), + Tiger_Security::hashCode('bbbb2222', 'recovery'), + ]; + $this->cred->replaceTotp($user, Tiger_Crypto::encrypt('JBSWY3DPEHPK3PXP'), $hashes); + $this->assertSame(2, $this->cred->recoveryCount($user)); + + $this->assertFalse($this->cred->redeemRecoveryCode($user, 'zzzz9999'), 'a wrong recovery code fails'); + // Present it with the punctuation a UI shows — normalization must still match. + $this->assertTrue($this->cred->redeemRecoveryCode($user, 'AAAA-1111'), 'a correct code redeems (normalized, case-insensitive)'); + $this->assertFalse($this->cred->redeemRecoveryCode($user, 'aaaa1111'), 'single-use: the same code cannot be redeemed twice'); + $this->assertSame(1, $this->cred->recoveryCount($user), 'exactly one code was burned'); + } + + #[Test] + public function the_lockout_counter_climbs_on_failure_and_resets_on_success(): void + { + $user = $this->makeUser(); + $id = $this->cred->setPassword($user, 'whatever123'); + + $this->assertFalse($this->cred->isLockedOut($this->cred->passwordCredential($user)), 'fresh credential is not locked'); + + for ($i = 0; $i < Tiger_Model_UserCredential::MAX_FAILURES; $i++) { + $this->cred->recordFailure($id); + } + $locked = $this->cred->passwordCredential($user); + $this->assertSame(Tiger_Model_UserCredential::MAX_FAILURES, (int) $locked->failed_count, 'each failure increments the counter'); + $this->assertNotNull($locked->locked_until, 'hitting MAX_FAILURES sets a lockout expiry'); + $this->assertTrue($this->cred->isLockedOut($locked), 'the credential is locked out'); + + $this->cred->recordSuccess($id); + $cleared = $this->cred->passwordCredential($user); + $this->assertSame(0, (int) $cleared->failed_count, 'success clears the failure counter'); + $this->assertNull($cleared->locked_until, 'success clears the lockout'); + $this->assertFalse($this->cred->isLockedOut($cleared)); + } + + #[Test] + public function a_totp_secret_is_encrypted_at_rest_and_decrypts_back(): void + { + $user = $this->makeUser(); + $plainB32 = 'JBSWY3DPEHPK3PXP'; // an RFC 4648 base32 shared secret + $this->cred->replaceTotp($user, Tiger_Crypto::encrypt($plainB32), []); + + $stored = (string) $this->cred->activeTotp($user)->secret; + $this->assertNotSame($plainB32, $stored, 'the raw column is NOT the plaintext secret'); + $this->assertStringNotContainsString($plainB32, $stored, 'the plaintext does not appear inside the ciphertext'); + $this->assertSame($plainB32, Tiger_Crypto::decrypt($stored), 'and it decrypts back to the original, losslessly'); + } +} diff --git a/tests/Integration/Model/UserTest.php b/tests/Integration/Model/UserTest.php new file mode 100644 index 0000000..3fe5224 --- /dev/null +++ b/tests/Integration/Model/UserTest.php @@ -0,0 +1,84 @@ +user = new Tiger_Model_User(); + $this->cred = new Tiger_Model_UserCredential(); + } + + private function makeUser(string $email, ?string $username = null): string + { + $data = ['email' => $email]; + if ($username !== null) { + $data['username'] = $username; + } + return $this->user->insert($data); + } + + #[Test] + public function email_and_username_resolve_identity_directly(): void + { + $tag = substr(Tiger_Uuid::v7(), 0, 12); + $id = $this->makeUser("who-$tag@example.test", "who-$tag"); + + $this->assertSame($id, $this->user->findByIdentifier("who-$tag@example.test")->user_id, 'email resolves'); + $this->assertSame($id, $this->user->findByIdentifier("who-$tag")->user_id, 'username resolves'); + $this->assertNull($this->user->findByIdentifier('nobody-here@example.test'), 'an unknown identifier resolves to nobody'); + $this->assertNull($this->user->findByIdentifier(' '), 'a blank identifier resolves to nobody'); + } + + #[Test] + public function only_a_verified_factor_identifier_resolves_to_the_user(): void + { + $id = $this->makeUser('phoneuser-' . substr(Tiger_Uuid::v7(), 0, 12) . '@example.test'); + $phone = '+1555' . substr((string) Tiger_Uuid::v7(), 0, 7); + + // An UNVERIFIED sms factor (verified_at NULL) must not resolve identity. + $credId = $this->cred->addSms($id, $phone); + $this->assertNull($this->user->findByIdentifier($phone), 'a pending/unverified factor does NOT log you in'); + + // Once confirmed, the same identifier resolves to its owner (login-by-phone). + $this->cred->markVerified($credId); + $this->assertSame($id, $this->user->findByIdentifier($phone)->user_id, 'a verified factor identifier resolves'); + } + + #[Test] + public function is_taken_enforces_email_and_username_uniqueness(): void + { + $tag = substr(Tiger_Uuid::v7(), 0, 12); + $id = $this->makeUser("taken-$tag@example.test", "taken-$tag"); + + $this->assertTrue($this->user->isTaken('email', "taken-$tag@example.test"), 'the email is taken'); + $this->assertTrue($this->user->isTaken('username', "taken-$tag"), 'the username is taken'); + $this->assertFalse($this->user->isTaken('email', "free-$tag@example.test"), 'a fresh email is free'); + + // The owner excluding itself is NOT a collision (the profile-edit case). + $this->assertFalse($this->user->isTaken('email', "taken-$tag@example.test", $id), 'excluding the owner clears the collision'); + $this->assertFalse($this->user->isTaken('nonsense', 'x'), 'an unknown column is never "taken"'); + } +} diff --git a/tests/Integration/Model/VersioningTest.php b/tests/Integration/Model/VersioningTest.php new file mode 100644 index 0000000..9470a7d --- /dev/null +++ b/tests/Integration/Model/VersioningTest.php @@ -0,0 +1,115 @@ +pv = new Tiger_Model_PageVersion(); + $this->cv = new Tiger_Model_CodeVersion(); + } + + #[Test] + public function page_first_version_is_one_and_increments_per_snapshot(): void + { + $pageId = Tiger_Uuid::v7(); + + $this->assertSame(1, $this->pv->nextVersion($pageId), 'an unversioned page starts at 1'); + + $this->assertSame(1, $this->pv->snapshot($pageId, ['title' => 'v1', 'body' => 'one', 'status' => 'draft'])); + $this->assertSame(2, $this->pv->snapshot($pageId, ['title' => 'v2', 'body' => 'two', 'status' => 'published'])); + $this->assertSame(3, $this->pv->snapshot($pageId, ['title' => 'v3', 'body' => 'three', 'status' => 'published'])); + + $this->assertSame(4, $this->pv->nextVersion($pageId), 'nextVersion tracks MAX(version)+1'); + } + + #[Test] + public function page_version_streams_are_independent_per_owner(): void + { + $pageA = Tiger_Uuid::v7(); + $pageB = Tiger_Uuid::v7(); + + $this->pv->snapshot($pageA, ['title' => 'A1']); + $this->pv->snapshot($pageA, ['title' => 'A2']); + // Page B's history is untouched by A's two snapshots. + $this->assertSame(1, $this->pv->nextVersion($pageB), 'B is unaffected by A'); + $this->assertSame(1, $this->pv->snapshot($pageB, ['title' => 'B1']), 'B starts its own count at 1'); + $this->assertSame(3, $this->pv->nextVersion($pageA), 'A continues its own count independently'); + } + + #[Test] + public function page_version_get_and_history_read_back_the_snapshot(): void + { + $pageId = Tiger_Uuid::v7(); + $this->pv->snapshot($pageId, ['title' => 'First', 'body' => 'b1', 'format' => 'markdown', 'status' => 'draft']); + $this->pv->snapshot($pageId, ['title' => 'Second', 'body' => 'b2', 'format' => 'html', 'status' => 'published']); + + $v1 = $this->pv->get($pageId, 1); + $this->assertNotNull($v1); + $this->assertSame('First', $v1->title); + $this->assertSame('markdown', $v1->format, 'the snapshotted format is stored intact'); + $this->assertNull($this->pv->get($pageId, 99), 'a missing version is null'); + + // recentForPage is newest-first. + $recent = $this->pv->recentForPage($pageId); + $this->assertSame(2, (int) $recent->current()->version, 'history is newest-first'); + } + + #[Test] + public function code_first_version_is_one_and_increments_independently(): void + { + $codeA = Tiger_Uuid::v7(); + $codeB = Tiger_Uuid::v7(); + + $this->assertSame(1, $this->cv->nextVersion($codeA), 'an unversioned code row starts at 1'); + + $this->assertSame(1, $this->cv->snapshot($codeA, ['name' => 'slug', 'language' => 'php', 'code' => ' 'active'])); + $this->assertSame(2, $this->cv->snapshot($codeA, ['name' => 'slug', 'language' => 'php', 'code' => ' 'active'])); + + // A different code row keeps its own independent stream. + $this->assertSame(1, $this->cv->snapshot($codeB, ['name' => 'other', 'language' => 'js', 'code' => '// b1'])); + $this->assertSame(3, $this->cv->nextVersion($codeA), 'code A keeps counting from its own MAX'); + $this->assertSame(2, $this->cv->nextVersion($codeB)); + } + + #[Test] + public function code_version_get_reads_the_snapshotted_fields(): void + { + $codeId = Tiger_Uuid::v7(); + $this->cv->snapshot($codeId, ['name' => 'helper', 'language' => 'php', 'code' => ' 'global', 'priority' => 50, 'active' => 1, 'status' => 'active']); + + $v1 = $this->cv->get($codeId, 1); + $this->assertNotNull($v1); + $this->assertSame('helper', $v1->name); + $this->assertSame('php', $v1->language); + $this->assertSame('code, 'the exact code body is snapshotted'); + $this->assertSame(1, (int) $v1->active); + $this->assertNull($this->cv->get($codeId, 2), 'a missing version is null'); + } +} diff --git a/tests/Integration/Policy/PasswordPolicyTest.php b/tests/Integration/Policy/PasswordPolicyTest.php new file mode 100644 index 0000000..aca6d82 --- /dev/null +++ b/tests/Integration/Policy/PasswordPolicyTest.php @@ -0,0 +1,105 @@ +policy = new Tiger_Policy_Password(); + $this->cred = new Tiger_Model_UserCredential(); + $this->configure([]); // defaults, no pepper + } + + /** Seed tiger.password.* (and optionally a pepper) into the resolved config the policy reads. */ + private function configure(array $password, ?string $pepper = null): void + { + $tiger = ['password' => $password]; + if ($pepper !== null) { + $tiger['security'] = ['pepper' => $pepper]; + } + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => $tiger], true)); + } + + private function makeUser(): string + { + return (new Tiger_Model_User())->insert(['email' => 'pol-' . bin2hex(random_bytes(8)) . '@example.test']); + } + + #[Test] + public function min_length_is_enforced(): void + { + $this->configure(['min_length' => 10]); + $this->assertContains('password.too_short', $this->policy->validate('short'), 'a sub-min password is rejected'); + $this->assertNotContains('password.too_short', $this->policy->validate('longenoughpassword'), 'a long password passes length'); + $this->assertTrue($this->policy->isValid('longenoughpassword'), 'and isValid agrees when nothing else applies'); + } + + #[Test] + public function complexity_is_enforced_only_when_enabled(): void + { + $this->configure(['min_length' => 4, 'require_complexity' => 0]); + $this->assertNotContains('password.needs_complexity', $this->policy->validate('alllowercase'), 'complexity off: a simple password passes'); + + $this->configure(['min_length' => 4, 'require_complexity' => 1]); + $this->assertContains('password.needs_complexity', $this->policy->validate('alllowercase'), 'complexity on: a simple password is rejected'); + $this->assertNotContains('password.needs_complexity', $this->policy->validate('Aa1!aaaa'), 'a mixed-class password satisfies complexity'); + } + + #[Test] + public function reuse_is_caught_against_the_current_and_retired_hashes(): void + { + $this->configure(['min_length' => 4, 'history' => 5], self::PEP_A); + $user = $this->makeUser(); + + // First password → current; second → archives the first into history, current becomes the second. + $this->cred->setPassword($user, 'firstpass1'); + $this->cred->setPassword($user, 'secondpass2'); + + $this->assertContains('password.reused', $this->policy->validate('secondpass2', $user), 'the CURRENT password is reuse'); + $this->assertContains('password.reused', $this->policy->validate('firstpass1', $user), 'a RETIRED (archived) password is reuse'); + $this->assertNotContains('password.reused', $this->policy->validate('brandnew3', $user), 'an unused password is not reuse'); + // With no userId, reuse-prevention is not engaged at all. + $this->assertSame([], $this->policy->validate('secondpass2'), 'without a user, reuse is not checked'); + } + + #[Test] + public function reuse_is_caught_across_the_pepper_boundary(): void + { + // Store the passwords with NO pepper (legacy hashes), then turn the pepper ON. + $this->configure(['min_length' => 4, 'history' => 5]); // no pepper + $user = $this->makeUser(); + $this->cred->setPassword($user, 'legacyfirst1'); + $this->cred->setPassword($user, 'legacysecond2'); // archives legacyfirst1 + + $this->configure(['min_length' => 4, 'history' => 5], self::PEP_A); // pepper now ON + $this->assertContains('password.reused', $this->policy->validate('legacysecond2', $user), 'a pre-pepper current hash is still caught as reuse'); + $this->assertContains('password.reused', $this->policy->validate('legacyfirst1', $user), 'a pre-pepper RETIRED hash is still caught after the pepper is added'); + } +} From f766ee1245ae2528fa15fdd5afc10d544e212903 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 24 Jul 2026 13:04:30 -0400 Subject: [PATCH 2/2] =?UTF-8?q?docs(tests):=20COVERAGE-PLAN=20=C2=A79=20?= =?UTF-8?q?=E2=80=94=20Wave=202=20progress=20+=20findings=20+=20next=20wav?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/COVERAGE-PLAN.md | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/tests/COVERAGE-PLAN.md b/tests/COVERAGE-PLAN.md index 9b3f1c3..2b1f3a3 100644 --- a/tests/COVERAGE-PLAN.md +++ b/tests/COVERAGE-PLAN.md @@ -486,11 +486,38 @@ Plus **~31 tiger-core migrations** — one "migrate up→down on a clean DB" int on `ZipArchive`/`PharData` flattening `../`; the shell fallbacks (`unzip`/`tar`) also refuse traversal. No bug today; `InstallerExtractTest` pins the escape-proof invariant so a future extractor swap fails loud. +**2026-07-24 — Wave 2 (integration/DB model spine, +102 tests → ~136 unit / ~110 integration).** 4 parallel +agents, each on an isolated DB, collected + verified together (111 integration tests green on one DB): +- ✅ **Data-layer base** — `Model_Table` (v7/v4 mint, actor+org stamp immutability, soft-delete + + `activeSelect`/`findById` exclusion), `Config`/`Option` scope isolation, `Db_Migrator` + (ordering/idempotency/apply-after-success/rollback). *(§6.1)* +- ✅ **Auth models** — `AuthChallenge`, `UserCredential` (pepper-migration, TOTP-at-rest, lockout, recovery), + `User` (verified-factor identity), `PasswordHistory`, `Policy_Password`. *(§6.1)* +- ✅ **Content/CMS** — `Page` (org cascade + publish gate), `Media` (private-URL scoping), `Menu`, `Org`, + `Page/CodeVersion`. *(§6.1)* +- ✅ **ACL/module/session** — `Acl{Resource,Role,Rule}` (deleted-rule exclusion), `Module` (`inactiveSlugs`), + `Login`, `Session` (`gc`), `Translation`. *(§6.1)* + +**FIXED (a Wave-2 test surfaced it):** `Tiger_Model_PasswordHistory::recentForUser()` ignored its `$limit` +(a Select passed to `fetchAll()` drops ZF1's `$count` arg → returned ALL retained rows; stricter-than- +configured, not a hole). Limit moved onto the Select; regression test pins it. + +**Findings (tracked, unchanged):** +4. **ACL loaders filter `deleted=0` only, not `status`** (`Model/Table.php` `activeSelect()`): a + `status='inactive'` acl_rule still loads + affects decisions. Fine if soft-delete is the only intended + "off", latent if `status` is ever expected to disable. +5. **`AuthChallenge::redeem()` single-use is a non-atomic TOCTOU** — check-then-consume + a read-modify-write + `attempts` counter; safe single-threaded, wants a conditional `UPDATE … WHERE consumed_at IS NULL` (+ + affected-rows) under concurrent load. (Same shape worth auditing in other redeem/counter paths.) +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. + ### Next waves (unwritten — priority order per §5/§8) -- **Wave 2 — integration/DB P1 spine:** `Model_Table` (tenant stamp + soft-delete), `Model_{AuthChallenge, - UserCredential,User}`, `Service_Authentication`, the module `/api` services (`Signup`, `Code`, `System_Modules`). - Single shared DB → run coordinated, not a wide fan-out. -- **Wave 3 — satellite repos:** stand up a harness in each, then TigerShield WAF engines (`Waf`/`Blocklist`/ +- **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 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. ---