Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions library/Tiger/Model/PasswordHistory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}

Expand Down
35 changes: 31 additions & 4 deletions tests/COVERAGE-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
179 changes: 179 additions & 0 deletions tests/Integration/MigratorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.

namespace Tiger\Tests\Integration;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Tiger\Tests\Support\IntegrationTestCase;
use Tiger_Db_Migrator;

/**
* Tiger_Db_Migrator — the tiny, dependency-free schema runner. Verified against a real MariaDB with
* THROWAWAY fixture migrations in a temp dir, so the core-migrated schema is never disturbed.
*
* The invariants under test are the ones the platform's whole schema lifecycle rests on:
* - version ordering (ascending, string-sorted zero-padded);
* - idempotency (a re-run applies nothing);
* - the `tiger_migration` ledger records exactly the applied versions;
* - APPLY-ONLY-AFTER-ALL-SUCCEED — a migration whose statements don't all succeed is left UNrecorded
* (so the next run retries it), never half-marked done;
* - rollback() runs the down statements and deletes the ledger row;
* - status() reflects applied vs pending.
*
* IMPORTANT ISOLATION NOTE: MySQL/MariaDB auto-commits DDL, which ends the base class's per-test
* transaction — so these DDL-driven tests can't rely on the tearDown rollback for cleanup. This test
* therefore uses out-of-range fixture versions (9xxx, far above any real migration) and distinctive
* `zzz_mig_*` table names, and cleans BOTH up explicitly (drop tables + delete ledger rows).
*/
#[CoversClass(Tiger_Db_Migrator::class)]
final class MigratorTest extends IntegrationTestCase
{
/** Fixture versions this test may create — purged from the shared ledger in tearDown. */
private const FIXTURE_VERSIONS = ['9001', '9002', '9003', '9101', '9102', '9201'];

/** Throwaway tables the fixtures may create — dropped in tearDown. */
private const THROWAWAY_TABLES = [
'zzz_mig_a', 'zzz_mig_b', 'zzz_mig_c', 'zzz_mig_p1', 'zzz_mig_p2', 'zzz_mig_r',
];

/** @var string[] temp dirs created this test, removed in tearDown */
private array $tmpDirs = [];

protected function tearDown(): void
{
// DDL auto-committed past the test transaction, so undo the side effects by hand.
foreach (self::THROWAWAY_TABLES as $t) {
try { $this->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 "<?php return ['up' => [\"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' => "<?php return ['up' => ["
. "\"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');
}
}
142 changes: 142 additions & 0 deletions tests/Integration/Model/AclModelsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.

namespace Tiger\Tests\Integration\Model;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Tiger\Tests\Support\IntegrationTestCase;
use Tiger_Model_AclResource;
use Tiger_Model_AclRole;
use Tiger_Model_AclRule;

/**
* The DB tier that feeds authorization — `acl_resource` / `acl_role` / `acl_rule`. These three
* loaders (`getResourceList` / `getRoleList` / `getRuleList`) are what `Tiger_Acl_Acl` reads on top
* of the code-shipped ini policy, and the DB tier loads LAST so it *wins* on conflict. That makes
* the soft-delete contract security-critical: a **dropped** rule must not keep flipping a decision
* and a **soft-deleted** allow row must not keep granting. Each loader builds on `activeSelect()`,
* so the invariant under test is "deleted rows are NOT in the active set" — exercised directly by
* seeding `acl_*` rows and asserting exactly what comes back.
*/
#[CoversClass(Tiger_Model_AclResource::class)]
#[CoversClass(Tiger_Model_AclRole::class)]
#[CoversClass(Tiger_Model_AclRule::class)]
final class AclModelsTest extends IntegrationTestCase
{
private Tiger_Model_AclResource $resources;
private Tiger_Model_AclRole $roles;
private Tiger_Model_AclRule $rules;

protected function setUp(): void
{
parent::setUp();
$this->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');
}
}
Loading
Loading