diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..83b2afd
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,84 @@
+name: Tests
+
+# Unit + integration test suites (see tests/COVERAGE-PLAN.md). Complements the existing gates:
+# version-check (tag == VERSION), smoke (boots/migrates/serves), release-zip (vendored ZIP).
+# This one runs the actual PHPUnit suites on every push to main and every PR.
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: tests-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ # Fast, no services — the bulk of coverage. Full PHP matrix (mirrors smoke.yml).
+ unit:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.1', '8.3', '8.4', '8.5']
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up PHP ${{ matrix.php }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: mbstring, intl, curl, zip, gd, sodium, apcu
+ ini-values: apc.enable_cli=1, memory_limit=512M
+ tools: composer:v2
+ coverage: none
+
+ - name: Install dependencies
+ run: composer install --no-interaction --no-progress --prefer-dist
+
+ - name: Unit suite
+ run: composer test:unit
+
+ # Needs a database — one PHP version is enough; the schema, not the interpreter, is under test.
+ integration:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ services:
+ mariadb:
+ image: mariadb:10.11
+ env:
+ MARIADB_ROOT_PASSWORD: root
+ MARIADB_DATABASE: tiger_test
+ ports: ['3306:3306']
+ options: >-
+ --health-cmd="healthcheck.sh --connect --innodb_initialized"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=15
+ env:
+ TIGER_TEST_DB_HOST: 127.0.0.1
+ TIGER_TEST_DB_PORT: 3306
+ TIGER_TEST_DB_NAME: tiger_test
+ TIGER_TEST_DB_USER: root
+ TIGER_TEST_DB_PASS: root
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ extensions: mbstring, intl, pdo_mysql, curl, zip, gd, sodium, apcu
+ ini-values: apc.enable_cli=1, memory_limit=512M
+ tools: composer:v2
+ coverage: none
+
+ - name: Install dependencies
+ run: composer install --no-interaction --no-progress --prefer-dist
+
+ - name: Integration suite (real MariaDB schema)
+ run: composer test:integration
diff --git a/composer.json b/composer.json
index 7edf63b..fbb8193 100644
--- a/composer.json
+++ b/composer.json
@@ -1,8 +1,14 @@
{
"name": "webtigers/tiger-core",
- "description": "Tiger platform Core — kernel + multi-tenant substrate (org/user/org_user/ACL) on TigerZF",
+ "description": "Tiger platform Core \u2014 kernel + multi-tenant substrate (org/user/org_user/ACL) on TigerZF",
"type": "library",
- "keywords": ["tiger", "saas", "multi-tenant", "zf1", "tigerzf"],
+ "keywords": [
+ "tiger",
+ "saas",
+ "multi-tenant",
+ "zf1",
+ "tigerzf"
+ ],
"homepage": "https://github.com/WebTigers/tiger-core/",
"license": "BSD-3-Clause",
"require": {
@@ -31,5 +37,18 @@
],
"config": {
"sort-packages": true
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5 || ^11.5 || ^12.0"
+ },
+ "scripts": {
+ "test": "phpunit",
+ "test:unit": "phpunit --testsuite unit",
+ "test:integration": "phpunit --testsuite integration"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tiger\\Tests\\": "tests/"
+ }
}
-}
+}
\ No newline at end of file
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..1f223e0
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+ tests/Unit
+
+
+ tests/Integration
+
+
+
+
+
+ library/Tiger
+ modules
+ core/controllers
+
+
+
+ library/Tiger/Cms/vendor
+
+ migrations
+ modules/*/languages
+ core/languages
+
+
+
+
+
+
+
diff --git a/tests/COVERAGE-PLAN.md b/tests/COVERAGE-PLAN.md
new file mode 100644
index 0000000..9b3f1c3
--- /dev/null
+++ b/tests/COVERAGE-PLAN.md
@@ -0,0 +1,498 @@
+# Tiger — Test Coverage Manifest (TEMPORARY / WORKING DOC)
+
+> **Purpose.** A comprehensive, class-by-class inventory of untested code across the public
+> **WebTigers/Tiger*** repositories, to drive writing a test suite toward **near-100% coverage**.
+> This pairs with the `tiger-core/BACKLOG.md` "Issues / tech debt" item: *"Automated test suite +
+> CI — none yet for Tiger's own code (gates 1.0)."*
+>
+> **Status:** inventory generated 2026-07-18; **harness promoted to `main` + CI wired 2026-07-24** (this
+> doc now lives in the repo). The blocking prerequisite in §3 is **DONE** — `phpunit.xml`, `tests/bootstrap.php`,
+> the `Tiger\Tests\Support\{Unit,Integration}TestCase` bases, the `composer test:*` scripts, and the
+> `.github/workflows/tests.yml` matrix (unit PHP 8.1–8.5 + one integration job on MariaDB) are all live.
+>
+> **Coverage as of 2026-07-24 (17 test files — the two previously-disjoint sets, now unioned on `main`):**
+> - **Unit — 128 tests / ~10.3k assertions:** `Tiger_Acl_Acl`, `Tiger_Ajax_ServiceFactory`, `Tiger_Auth_Totp`,
+> `Tiger_Crypto`, `Tiger_Security`, `Tiger_Uuid`, `Tiger_Crypto_Signature`, `Tiger_License_{Authority,Checker}`,
+> `Tiger_Module_{Pricing,Compat,Dependency,Installer(signature)}`, agent attachment/loop.
+> - **Integration — 8 tests (real MariaDB):** `Tiger_Model_OrgUser`, schema+model migrate-up smoke.
+>
+> **Still to write (the bulk):** the priority spine in §5/§8 — the P1 crypto/auth models, the model/tenant
+> DB layer (§6.1), the kernel P1s (§6.2), media/storage adapters (§6.3), the module `/api` services (§6.4),
+> and the satellite modules (§6.5). **New since the 2026-07-18 scan (add to the inventory as covered):** the
+> licensing/marketplace client (`Tiger_License_*`, `Tiger_Crypto_Signature`, `Tiger_Module_{Pricing,Compat}`),
+> TigerAgent's attachment layer, and the commerce module repos (TigerStripe/Membership/Shop/Download/List/
+> Roundtable/License — each its own repo + harness). This file is the running tracker for that backfill.
+
+---
+
+## 1. Scope & filters
+
+Rule applied: **public repos only, `Tiger*`-family only, private repos excluded.** Enumerated from
+`gh repo list WebTigers` (18 repos total).
+
+| Repo (GH) | Local dir | Lang | In scope? | Why |
+|---|---|---|---|---|
+| **WebTigers/TigerCore** | `tiger-core` | PHP (196 first-party .php) | ✅ **primary** | the framework — kernel, substrate, modules; **0 tests** |
+| **WebTigers/TigerDocs** | `TigerDocs` | PHP (12) | ✅ | docs engine + reference generator; 0 tests |
+| **WebTigers/TigerShield** | `TigerShield` | PHP (20) | ✅ | BSD WAF — block/rate-limit decisions; 0 tests |
+| **WebTigers/TigerAPIDocs** | `TigerAPIDocs` | PHP (3) | ✅ | Swagger UI module; 0 tests |
+| **WebTigers/TigerCodeSnippets** | `TigerCodeSnippets` | PHP (6) | ✅ | 6 pure helper *functions* — ideal easy units; 0 tests |
+| **WebTigers/TigerVendors** | `Vendors` | PHP (3) | ✅ (partial) | registry review/compile scripts; 0 tests |
+| **WebTigers/Tiger** | `Tiger` | PHP (5) | ✅ (thin) | app skeleton — Bootstrap/index/custom; mostly glue |
+| **WebTigers/tiger-install** | `tiger-install` | PHP (1) | ✅ (thin) | installer; 0 tests |
+| **WebTigers/TigerUpload** | `TigerUpload` | JS | ✅ (JS suite) | headless upload engine; separate JS tooling |
+| **WebTigers/TigerLightbox** | `TigerLightbox` | JS | ✅ (JS suite) | lightbox/gallery; separate JS tooling |
+| **WebTigers/TigerZF** | `TigerZF` | PHP (8957) | ⚠️ **special case** | see §2 — already has 1,210 test files / upstream ZF1 baseline |
+| WebTigers/TigerSponsors | `Sponsors` | JSON | ❌ | ranks-only data repo, no classes |
+| WebTigers/theme-grey-mist | `theme-grey-mist` | HTML/CSS | ❌ | a theme — 0 PHP classes (view scripts only) |
+| WebTigers/tiger-vendor-bundles | `tiger-vendor-bundles` | Shell | ❌ | CI bundle-builder scripts, no PHP classes |
+| WebTigers/tigerDOM | *(not cloned)* | JS (2022) | ❌ | superseded by `tiger.dom.js` in the PUMA theme; dead |
+| WebTigers/TigerDocs-content | *private* | — | ❌ | **private repo — excluded by rule** |
+| WebTigers/AskLevi | *private* | — | ❌ | **private — excluded** (reference app, not shipped) |
+| WebTigers/DonnaAI | *private* | — | ❌ | **private — excluded** |
+
+Note: local `TigerCore` and `tiger-core` are **two checkouts of the same remote** (WebTigers/TigerCore).
+Canonical working copy = `tiger-core`; the `TigerCore` dir is a stale duplicate and is ignored.
+
+---
+
+## 2. TigerZF — the special case (already covered)
+
+TigerZF ships **1,210 `*Test.php` files** (the upstream `Shardj/zf1-future` baseline, ~10,796 tests
+/ 359,814 assertions on PHP 8.5). `library/` contains **only `Zend/`** — WebTigers added **no net-new
+classes**; the modernization was *fixes to existing Zend_\* classes*, which the existing suite
+exercises (current baseline: 3 failures, all host-environment artifacts, not real regressions).
+
+**Verdict: excluded from the "needs tests written" scope.** It is not untested. The only follow-on
+work here is keeping the matrix green, not authoring new tests. *(If WebTigers ever adds a genuinely
+new `Zend_*`/`Tiger_*` class into this repo, it lands in this manifest then — none exist today.)*
+
+---
+
+## 3. The blocking prerequisite (do this before writing a single test)
+
+**None of the in-scope repos has a PHPUnit harness.** tiger-core has no `phpunit.xml`, no `tests/`
+dir, no `TestConfiguration`, no CI workflow for its own code. So step zero is standing up the harness,
+and it is a real design task because of Tiger's shape:
+
+- **Bootstrap/autoload** — PSR-0 underscore `Tiger_*` + `Zend_*` from `vendor/`; a test bootstrap must
+ wire the include path the same way `Tiger_Application` does, without running a full web bootstrap.
+- **DB-touching classes need a fixture DB** — models (`Tiger_Model_*`), `/api` services (validate →
+ transaction), and the migrator. Decide: a throwaway MySQL schema via the migrations, an in-memory
+ SQLite shim (risky — models use `Zend_Db_Expr`, `NOW()`, `FULLTEXT`, JSON columns; SQLite will
+ diverge), or a transactional-rollback-per-test pattern against a real MySQL. **Recommend real MySQL
+ + migrate-once + per-test transaction rollback.**
+- **Static registries** (`Tiger_Routing_Overrides`, `Tiger_Admin_Settings`, `Tiger_Code_Modules`,
+ `Tiger_Cms_Renderer` shortcodes) hold process state — tests must reset them between cases.
+- **Crypto/secrets** — `Tiger_Crypto`/`Tiger_Security` need a test key + pepper (not the dev ones).
+- **CI** — mirror TigerZF's matrix (PHP 8.1–8.5) once green; it's the gate for the 1.0 `@api` freeze.
+
+CI/harness gating is tracked separately in memory ([[tiger-core-release-gating]] — main is already
+PR-gated on a smoke test); this manifest is the *unit-coverage* companion to that.
+
+---
+
+## 4. Classification rubric (how to read the tables)
+
+Each class row: `class | path | kind | public methods | testability | priority | sec | note`
+
+- **kind** — model · service · controller · form · plugin · helper · value · factory · adapter ·
+ abstract · interface · trait · console · script
+- **testability** — the dominant harness need:
+ - `pure` (no deps — write these first, cheapest coverage) · `static` (static state, reset between
+ tests) · `db` (fixture DB) · `http` (request/response doubles) · `fs` (filesystem/temp dirs) ·
+ `net` (external SDK/HTTP — mock or contract-test) · `crypto` (test key/pepper)
+- **priority** — **P1** security/data-integrity-critical · **P2** core substrate/kernel · **P3**
+ feature module · **P4** glue/trivial
+- **sec** — `yes` if a bug is a security or tenant-isolation/data-integrity risk
+
+### Coverage-math honesty
+"Near-100%" should be measured against **testable logic**, not raw line count. Realistically:
+- **Controllers** (`*Action`, render the shell) and **view scripts** — thin by design (ADMIN.md:
+ "thin controllers, fat services"). Cover via a few dispatch/smoke tests; don't chase per-line.
+- **Migrations** (`migrations/NNNN_*.php`, ~31 files) — return `['up','down']` arrays, not classes.
+ Cover by a single **"migrate up on a clean DB, then down"** integration test, not per-file.
+- **Bootstraps** / near-empty glue — smoke only.
+- The **coverage budget belongs on** `Tiger_Model_*`, `Tiger_Service_*`, `Tiger_Acl_*`,
+ `Tiger_Ajax_ServiceFactory`, auth/crypto, `Tiger_Module_Installer`, storage adapters, and the module
+ `/api` services — the P1/P2 logic.
+
+---
+
+## 5. Priority spine (write tests in this order)
+
+From `BACKLOG.md` ("security-critical paths first") + the audit:
+
+1. **`/api` gateway guard** — `Tiger_Ajax_ServiceFactory`: reserved-module refusal (`tiger`/`zend`/
+ `core`/…), class-name sanitization (`[a-zA-Z]` strip, no path traversal), `extends
+ Tiger_Service_Service` type guard, deny-by-default ACL.
+2. **ACL** — `Tiger_Acl_Acl`: deny-by-default, role-on-membership resolution, `explain()`, inheritance.
+3. **Auth/session** — login + lockout, `auth_challenge` issue/redeem (single-use/TTL/attempt cap),
+ password history/pepper, TOTP vectors, session TTL tiers.
+4. **Crypto** — `Tiger_Crypto`/`Tiger_Security`: encrypt/decrypt round-trip, pepper HMAC, key/pepper
+ **rotation** (retired-secret fallback), constant-time verify.
+5. **Module install** — `Tiger_Module_Installer`: **zip-slip guard**, checksum verify, pinned-ref.
+6. **Vendor/update** — `Tiger_Vendor` + `Tiger_Update_Core`: download→verify→atomic swap→rollback.
+7. **Tenant isolation** — `Tiger_Model_Table` (UUID mint, actor stamp, soft-delete `activeSelect`),
+ org-scoping in models, storage-key traversal guards, private-file visibility.
+8. **Code execution** — `Tiger_Code_Runtime` compile/lint gate; `Code_Service_Code` superadmin gate.
+9. **Input validation** — `Tiger_Validate_*`, `Tiger_Form::convenienceValidate`.
+10. Everything P2, then P3 features, then P4 smoke.
+
+---
+
+## 6. Class inventory (per repo)
+
+> Filled from six parallel read-only scans. Rows are `class | path | kind | #pub | testability |
+> priority | sec | note`.
+
+### 6.1 tiger-core — library/Tiger/Model, Db, Validate, Policy *(data layer)* — 35 classes
+
+**P1 / security-critical (write first):**
+
+| class | path | kind | #pub | test | note |
+|---|---|---|---|---|---|
+| Tiger_Model_UserCredential | Model/UserCredential.php | model | 24 | crypto | ⚠️ durable auth factors — `verifyPassword` pepper-migration rehash, `verifyToken`/`redeemRecovery` constant-time, lockout counter, TOTP encrypt-at-rest + rekey |
+| Tiger_Model_Table | Model/Table.php | abstract | 10 | db | ⚠️ **base gateway** — UUID mint, actor + `org_id` tenant stamp, soft-delete; `activeSelect()` deleted-scoping, `findById` exclusion. *Every model inherits this — test it hardest.* |
+| Tiger_Model_OrgUser | Model/OrgUser.php | model | 4 | db | ⚠️ membership = tenancy boundary AND role carrier; `membership()`/`roleOf()` null-means-deny — a wrong hit crosses tenants or grants a role |
+| Tiger_Model_AuthChallenge | Model/AuthChallenge.php | model | 6 | db | ⚠️ single-use hashed OTP/reset; `redeem()` expiry + attempt-lock + consume ordering + timing-safe match |
+| Tiger_Model_User | Model/User.php | model | 4 | db | ⚠️ `findByIdentifier()` — only **verified** factors resolve identity; `isTaken` uniqueness |
+| Tiger_Model_Code | Model/Code.php | model | 9 | db | ⚠️ executable-PHP store (RCE surface) — `lint()` gate + server-lang platform-scope restriction before a row goes active |
+| Tiger_Policy_Password | Policy/Password.php | helper | 4 | db | ⚠️ per-org policy — `_isReused` across current+retired hashes over the pepper boundary + min-length/complexity/expiry |
+| Tiger_Validate_Password | Validate/Password.php | helper | 1 | db | ⚠️ form validator wrapping the policy; must not pass a weak/reused password |
+| Tiger_Validate_Recaptcha | Validate/Recaptcha.php | helper | 1 | net | ⚠️ server-side verify — fail-open-on-outage policy + v3 score threshold + action-replay + disabled pass-through |
+| Tiger_Model_PasswordHistory | Model/PasswordHistory.php | model | 3 | db | ⚠️ reuse-prevention — `prune()` bound + `recentForUser` ordering feeding policy |
+| Tiger_Model_AclResource | Model/AclResource.php | model | 1 | db | ⚠️ `activeSelect` excludes deleted/inactive resources correctly |
+| Tiger_Model_AclRole | Model/AclRole.php | model | 1 | db | ⚠️ correct active-role set feeding inheritance |
+| Tiger_Model_AclRule | Model/AclRule.php | model | 1 | db | ⚠️ a dropped/extra rule flips an authorization decision |
+
+**P2 / substrate:**
+
+| class | path | #pub | test | note |
+|---|---|---|---|---|
+| Tiger_Db_Migrator | Db/Migrator.php | 3 | db | ⚠️ version ordering + apply-only-after-all-succeed idempotency |
+| Tiger_Model_Page | Model/Page.php | 6 | db | ⚠️ `resolveBySlug` org cascade/publish gate + phtml trusted-code handling |
+| Tiger_Model_Media | Model/Media.php | 13 | db | ⚠️ `url()` public-direct vs private-ACL-streamer routing + `classify()` extension allowlist |
+| Tiger_Model_Org | Model/Org.php | 6 | db | ⚠️ `siteOrgId()` cache/founding-org heuristic + `slugTaken` |
+| Tiger_Model_Login | Model/Login.php | 6 | db | ⚠️ `recentFailures*` feeding rate-limit/lockout |
+| Tiger_Model_Session | Model/Session.php | 3 | db | ⚠️ `gc()` expiry math + `deleteByUserId` force-logout |
+| Tiger_Model_Config | Model/Config.php | 3 | db | scope/scope_id upsert isolation |
+| Tiger_Model_Module | Model/Module.php | 6 | db | `inactiveSlugs()` boot gate stripping controller dirs |
+
+**P3/P4 (thinner):** Tiger_Model_Option (5,db), Tiger_Model_Menu (8,db — reorder ownership guard),
+Tiger_Model_Translation (3,db), Tiger_Model_PageVersion (4,db — `nextVersion` MAX+1 race),
+Tiger_Model_CodeVersion (4,db, same race), Tiger_Model_PageRedirect (3,db — redirect-loop clear),
+Tiger_Model_Address (1,db), Tiger_Model_Contact (0,db), Tiger_Model_OrgAddress/OrgContact/UserAddress/UserContact
+(1 each,db — link finders), Tiger_Model_UpdateHistory (2,db), Tiger_Model_MessageObject (1,static),
+Tiger_Model_ResponseObject (0,pure — DTO).
+
+### 6.2 tiger-core — library/Tiger kernel — 60 classes (59 first-party + 1 vendored)
+
+**P1 / security-critical (the spine — §5 order lives mostly here):**
+
+| class | path | #pub | test | note |
+|---|---|---|---|---|
+| Tiger_Service_Authentication | Service/Authentication.php | 26 | db | ⚠️ **the auth engine** — login/logout, reset, 2FA/TOTP enroll+verify, recovery, lock, org switch, token identity. Timing-safe login (dummy hash), 2FA verify, reset challenge, lock flow |
+| Tiger_Ajax_ServiceFactory | Ajax/ServiceFactory.php | 4 | http | ⚠️ **/api dispatcher** — reserved-module guard, alpha-only segment→class sanitization, ACL authorize BEFORE instantiation, deny-by-default. Test reserve-guard bypass + param sanitization + authorize order |
+| Tiger_Acl_Acl | Acl/Acl.php | 2 | db | ⚠️ ACL engine — deny-by-default, ini-chain + DB rule load, `explain`, role chain, wildcards. Override order + wildcard + topological role add |
+| Tiger_Controller_Plugin_Authorization | Controller/Plugin/Authorization.php | 1 | http | ⚠️ deny-by-default on **every** preDispatch (resource = controller class); role resolve + deny redirect + module-qualified resource |
+| Tiger_Security | Security.php | 6 | crypto | ⚠️ password prehash+pepper, code hash/verify, pepper rotation keyring; timing-safe `codeMatches` |
+| Tiger_Crypto | Crypto.php | 5 | crypto | ⚠️ envelope encrypt/decrypt/reencrypt keyring; key rotation + tamper/decrypt-failure |
+| Tiger_Auth_Totp | Auth/Totp.php | 6 | crypto | ⚠️ TOTP gen/verify + base32; drift window + code-at-counter (RFC vectors) |
+| Tiger_Session_SaveHandler_DbTable | Session/SaveHandler/DbTable.php | 2 | db | ⚠️ DB session persistence, per-role TTL, identity binding on write; gc lifetime |
+| Tiger_Module_Installer | Module/Installer.php | 7 | fs | ⚠️ install from url/upload/tarball — **zip-slip guard**, slug validate, `checkRequires`, publish, migrate |
+| Tiger_Update_Core | Update/Core.php | 4 | net | ⚠️ resolve+download+extract core release, maintenance flag, **vendor swap** — download verify + zip-slip + rollback/health |
+| Tiger_Vendor | Vendor.php | 7 | net | ⚠️ dep installer — tarball download + **sha256 verify** + extract + autoloader gen + semver satisfies |
+| Tiger_Update_Composer | Update/Composer.php | 2 | fs | ⚠️ `composer update` via shell exec — command construction + timeout + failure log |
+| Tiger_Install | Install.php | 7 | db | ⚠️ owner creation + secret/storage provisioning + secret rotate/drop; rotate integrity + local.ini writes |
+| Tiger_Code_Runtime | Code/Runtime.php | 8 | fs | ⚠️ compiles + includes user PHP (client+server bundles), lint gate, shutdown guard — lint-bypass + arbitrary-code inclusion |
+| Tiger_Service_Token | Service/Token.php | 3 | db | ⚠️ API token create/list/revoke — ownership scoping to current user |
+| Tiger_Routing_Overrides | Routing/Overrides.php | 4 | static | ⚠️ override registry — `_isReserved` prefix collision (api/auth/admin) + mca parse |
+| Tiger_Service_Validate | Service/Validate.php | 1 | pure | ⚠️ builds `Module_Form_Name` from params — module/name sanitization + Tiger_Form-subclass check |
+
+**P2 / core kernel:**
+
+| class | path | #pub | test | note |
+|---|---|---|---|---|
+| Tiger_Application | Application.php | 2 | fs | ⚠️ boot/run, config build, proxy normalize, update gate — maintenance gate + forwarded-header trust |
+| Tiger_Application_Bootstrap | Application/Bootstrap.php | 0 | db | ⚠️ resource init order + DB secret resolution |
+| Tiger_Service_Service | Service/Service.php | 1 | db | ⚠️ service base — param b64 decode, dispatch, txn wrapper, admin gate, datatables; `_dispatch`/`_isAdmin`/`_decodeB64` |
+| Tiger_Form | Form.php | 2 | pure | ⚠️ base form — element build, CSRF default, translate, convenienceValidate; CSRF token lifetime |
+| Tiger_Application_Resource_Modules | Application/Resource/Modules.php | 1 | fs | ⚠️ activation gate — skips inactive/missing so a bad module can't brick boot |
+| Tiger_Controller_Admin_Action | Controller/Admin/Action.php | 1 | http | ⚠️ admin base — enforces admin ACL in init |
+| Tiger_Controller_Action | Controller/Action.php | 1 | http | base controller init + `_json` |
+| Tiger_Update_Checker | Update/Checker.php | 5 | net | update availability + changelog slice, file-cached |
+| Tiger_Module_Github | Module/Github.php | 6 | net | ⚠️ GitHub client — parseRepo, fetchRaw, latestRef, tarball download-to-file |
+
+**P3/P4 (features, helpers, glue, view helpers):** Tiger_Recaptcha (12,http,⚠️ failOpen+score),
+Tiger_Mail (8,net,⚠️ header injection), Tiger_Location (9,net,⚠️ secret encrypt/decrypt), Tiger_Theme
+(7,fs,⚠️ `page(slug)` traversal), Tiger_Cms_Renderer (3,pure,⚠️ shortcode injection/escaping),
+Tiger_Code_Modules (8,fs), Tiger_Menu (2,db), Tiger_Log (6,fs), Tiger_Dashboard (6,static),
+Tiger_OpenApi_Generator (3,fs), Tiger_Generator_Module (1,fs), Tiger_Vendor_Environment (8,fs),
+Tiger_Module_Registry (6,net), Tiger_Module_Discovery (1,fs), Tiger_Module_Dependency (3,fs),
+Tiger_Service_Location (3,net), Tiger_I18n_Country (6,pure), Tiger_Uuid (5,pure — v7 monotonic + isValid),
+Tiger_Sitemap (3,static), Tiger_Admin_Nav (3,static), Tiger_Admin_Settings (3,static), Tiger_Version
+(0,pure), the 4 Controller plugins ThemeContent/LocalePrefix/PageDispatch/RouteOverride (1 each,http),
+5 View helpers (Asset/Menu/CodeInject⚠️/MediaField/FormRecaptcha), Tiger_Form_Element_Recaptcha.
+
+**⛔ Vendored third-party (exclude from our coverage math):** `Parsedown` (Cms/vendor/Parsedown.php) —
+upstream lib; at most a **characterization test** of safe-mode/XSS-escape as *we* configure it, not
+line coverage.
+
+### 6.3 tiger-core — library/Tiger/Media + Location *(subsystems)* — 18 classes
+
+| class | path | kind | #pub | test | pri | sec | note |
+|---|---|---|---|---|---|---|---|
+| Tiger_Media_Storage_S3 | Media/Storage/S3.php | adapter | 9 | net | **P1** | ⚠️ | presigned-GET TTL + `_fullKey` `..` traversal guard + public/private prefix + public-read ACL |
+| Tiger_Media_Storage_Gcs | Media/Storage/Gcs.php | adapter | 9 | net | **P1** | ⚠️ | V4 signed URL + `_fullKey` traversal guard + public/private prefix mapping |
+| Tiger_Media_Storage_Azure | Media/Storage/Azure.php | adapter | 9 | net | **P1** | ⚠️ | SAS token signing + `_blob` `..` guard + one-vs-two-container visibility |
+| Tiger_Media_Storage_Filesystem | Media/Storage/Filesystem.php | adapter | 9 | fs | **P1** | ⚠️ | `_path` `..`/backslash traversal guard + public(docroot)-vs-private(outside) root split; private `url()`='' |
+| Tiger_Media_Scan | Media/Scan.php | service | 2 | fs | **P1** | ⚠️ | upload scan orchestrator (virus→image); fail-open on scanner error + infected/rejected→block mapping + config gating |
+| Tiger_Media_Scanner_ClamAv | Media/Scanner/ClamAv.php | adapter | 1 | fs | **P1** | ⚠️ | `escapeshellarg` on path + exit-code 0/1/2 → clean/infected/error |
+| Tiger_Location_Adapter_Aws | Location/Adapter/Aws.php | adapter | 6 | net | **P1** | ⚠️ | inline **SigV4** canonical-request/string-to-sign/signing-key + security-token path |
+| Tiger_Media_Scanner_Rekognition | Media/Scanner/Rekognition.php | adapter | 3 | net | P2 | ⚠️ | AI moderation threshold/MinConfidence + any-label→reject; degrades w/o SDK |
+| Tiger_Media_Storage | Media/Storage.php | factory | 3 | pure | P2 | no | disk-name→memoized adapter; unknown-adapter throw + `default_disk` fallback |
+| Tiger_Location_Adapter_Abstract | Location/Adapter/Abstract.php | abstract | 9 | http | P2 | no | adapter base: config, capability gating, curl-JSON helper; `_getJson` non-2xx→null degrade |
+| Tiger_Media_Image | Media/Image.php | helper | 3 | fs | P3 | no | GD variants (contain, never upscale, EXIF-orient); never-upscale skip + MIME→GD-type map |
+| Tiger_Location_Adapter_IpApi | Location/Adapter/IpApi.php | adapter | 4 | net | P3 | no | ip-api.com IP geo (dev default); `rawurlencode` URL + status!='success'→null |
+| Tiger_Location_Adapter_Nominatim | Location/Adapter/Nominatim.php | adapter | 6 | net | P3 | no | OSM search+reverse; country-bias querystring + address→Place mapping |
+| Tiger_Location_Place | Location/Place.php | value | 2 | pure | P3 | no | normalized place payload; ctor filters unknown keys, `toArray` drops raw blob |
+| Tiger_Media_Scanner_Interface | Media/Scanner/Interface.php | interface | 1 | pure | P3 | no | scanner contract (never throws); signature only |
+| Tiger_Location_Adapter_Interface | Location/Adapter/Interface.php | interface | 5 | pure | P4 | no | provider capability contract + CAP_* consts |
+| Tiger_Media_Storage_Interface | Media/Storage/Interface.php | interface | 8 | pure | P4 | no | storage-backend contract |
+| Tiger_Location_Exception | Location/Exception.php | value | 0 | pure | P4 | no | marker exception; no behavior |
+
+**Section note:** the 4 storage adapters share one traversal-guard + visibility contract — write a
+**single shared adapter test-trait** exercising `..`/backslash keys, public-vs-private URL shape, and
+(for cloud) signed-URL expiry, then run it against each. SigV4/SAS/V4 signing wants **known-answer
+vectors** (canonical request → signature), not live calls. `net` adapters get contract tests + one
+optional live smoke gated on creds.
+
+### 6.4 tiger-core — core/ + modules/* — 67 classes across 10 areas
+
+Most controllers are thin (render + admin-gate) → dispatch/smoke tests. The **`/api` services** hold
+the logic and carry the P1 weight (validate → transaction, server-computed ACL flags, user-input writes).
+
+**P1 / security-critical services (the real test surface here):**
+
+| class | area | path | #pub | test | note |
+|---|---|---|---|---|---|
+| System_Service_Modules | system | services/Modules.php | 6 | fs | ⚠️ activate/deactivate/install(GitHub URL)/upload(zip) — installs **UNTRUSTED module code**; gate, upload validation, `is_uploaded_file`/zip checks, RCE-via-installed-module |
+| Code_Service_Code | code | services/Code.php | 7 | db | ⚠️ save+activate **lint→`Tiger_Code_Runtime::rebuild()` compiles/executes** community PHP; RCE via lint bypass, redeclare conflict, `module:` toggle |
+| Signup_Service_Signup | signup | services/Signup.php | 2 | db | ⚠️ **GUEST-allowed** — builds org+user+credential+addr+contact in one txn from unauth input + email challenge; unauthenticated mass-creation, password set, single-use token redemption |
+| System_Service_Updates | system | services/Updates.php | 3 | fs | ⚠️ apply mutates installed code + records + rollback; `applyOne`, rollback detection |
+| System_Service_Dashboard | system | services/Dashboard.php | 3 | db | ⚠️ per-user `saveLayout`/`widgetBody` — arbitrary widget render |
+| System_Service_Acl | system | services/Acl.php | 2 | db | ⚠️ `simulate()`/`catalog()` server-computed ACL decisions — decision correctness |
+| Media_Service_Media | media | services/Media.php | 4 | fs | ⚠️ upload/update/delete — classify+mime-sniff, image variants, ACL flags; gate + upload + variant/path write |
+| Cms_Service_Page | cms | services/Page.php | 6 | db | ⚠️ page save/saveDesign/delete/restore + `forkTheme`; gate + page-HTML write + theme fork FS |
+| Cms_Service_Menu | cms | services/Menu.php | 5 | db | ⚠️ menu datatable/save/delete/reorder + ACL flags; nested-menu writes |
+| Cms_Service_Settings | cms | services/Settings.php | 1 | db | ⚠️ save site settings; gate + write |
+| Blog_Service_Post | blog | services/Post.php | 4 | db | ⚠️ save/delete/restore + version snapshot + 301 slug redirect; term/slug/HTML write |
+| Access_Service_User | access | services/User.php | 3 | db | ⚠️ datatable/save/delete + per-row ACL flags; gate + user-input write |
+| Access_Service_Org | access | services/Org.php | 3 | db | ⚠️ save/delete + slugify + ACL flags; parent-self guard + input write |
+| Media_Service_Settings | media | services/Settings.php | 1 | db | ⚠️ save storage/preset settings |
+| System_Service_Settings | system | services/Settings.php | 2 | db | ⚠️ global settings + `locationTest()` |
+| Identity_Service_Identity | identity | services/Identity.php | 1 | db | ⚠️ save brand (logo/favicon media ids, site name) |
+
+**P1/P2 controllers worth more than smoke:** `AuthController` (core, 12 actions — recaptcha gate +
+reset/otp token redemption), `ApiController` (core — ACL routing to ServiceFactory + discovery gating),
+`Signup_IndexController` (verify-token landing), `Media_FileController` (**private-file stream — path
+traversal + access scoping**), `Media_CallbackController` (upload-completion). `AdminController` (core —
+per-user widget layout).
+
+**P2 models / renderers:** `Blog_Model_Post` (7,db — meta pack + save over Tiger_Model_Page),
+`Blog_Model_Taxonomy` (8,db — syncPage + slugify), `PageController` (core — `_injectBefore` fragment
+injection).
+
+**seo module (already partly built — note for the TigerSEO scope doc):** `Seo_Service_Head` (static
+`forRow()` builds meta/OG/robots), `Seo_Service_Schema` (static JSON-LD site/breadcrumb/article),
+`Seo_Plugin_Head` (preDispatch inject), `Seo_RobotsController` + `Seo_SitemapController` (P3). These are
+`static`/`pure` → easy to unit-test, and cover the head-injection foundation.
+
+**P3/P4 (forms = pure element schemas; Bootstraps = _init glue):** all `*_Form_*` (Access/Blog/Cms/Code/
+Identity/Media/System/Signup — pure, except `Access_Form_Org` + `Signup_Form_Signup` do DB-uniqueness →
+db), the thin admin render controllers (Access/Blog/Cms/Code/Identity/Media/System list+edit),
+`ErrorController` (P4), `IndexController` (core marketing, P3), `Identity_Plugin_Favicon` (P4).
+
+**Non-class in scope (not tested as units):** 5 `Bootstrap.php` that are empty/`_init`-only
+(access/signup/code + the registering ones), and **16 language files** (`languages/*/*.php` return
+arrays) — cover indirectly via a "translation keys resolve" smoke test, not per-file.
+
+### 6.5 Satellite PHP modules — 33 classes + 3 P1 procedural files
+
+**TigerShield (WebTigers/TigerShield) — 14 classes; the WAF, so P1-dense**
+
+| class | path | kind | #pub | test | pri | sec | note |
+|---|---|---|---|---|---|---|---|
+| Tigershield_Plugin_Firewall | plugins/Firewall.php | plugin | 1 | http | **P1** | ⚠️ | front-controller allow/deny entrypoint (routeStartup); `_decide`/`_loginDecision`/`_rateLimitDecision`/`_block` + `_clientIp`/`_isAllowlisted` |
+| Tigershield_Service_Blocklist | services/Blocklist.php | service | 7 | fs | **P1** | ⚠️ | IP-in-CIDR lookup; `_inCidrBin` binary CIDR match + atomic `replace()` |
+| Tigershield_Service_Challenge | services/Challenge.php | service | 7 | crypto | **P1** | ⚠️ | captcha verify + HMAC-signed clearance cookie; `_sign`/`verifyClearance` forgery + window expiry |
+| Tigershield_Service_RateLimit | services/RateLimit.php | service | 3 | fs | **P1** | ⚠️ | token-bucket; `hit()` limit trip + window rollover |
+| Tigershield_Service_Waf | services/Waf.php | service | 2 | pure | **P1** | ⚠️ | signature/regex inspection → allow/deny; `_matchPattern`/`_matchNeedles` + body scan — **pure, high-value** |
+| Tigershield_Model_Rule | models/Rule.php | model | 4 | fs | P2 | ⚠️ | WAF rule store + `compileCache()` that drives block decisions |
+| Tigershield_Service_Crowdsec | services/Crowdsec.php | service | 6 | net | P2 | ⚠️ | pulls CrowdSec CAPI blocklist to cache; `_pullDecisions` parse + creds |
+| Tigershield_Model_Event | models/Event.php | model | 4 | db | P3 | no | event log: record/topIps/countSince/datatable |
+| Tigershield_Service_Rules | services/Rules.php | service | 4 | db | P3 | no | custom rule CRUD in transaction |
+| Tigershield_Service_Events | services/Events.php | service | 1 | db | P3 | no | datatable feed |
+| Tigershield_Service_Settings | services/Settings.php | service | 1 | db | P3 | no | persist config keys |
+| Tigershield_Widget_Shield | widgets/Shield.php | helper | 5 | db | P3 | no | dashboard widget stats |
+| Tigershield_AdminController | controllers/AdminController.php | controller | 4 | http | P3 | no | settings/events/rules screens |
+| Tigershield_Bootstrap | Bootstrap.php | plugin | 0 | db | P4 | no | registers firewall plugin + widget |
+
+**TigerDocs (WebTigers/TigerDocs) — 9 classes**
+
+| class | path | kind | #pub | test | pri | sec | note |
+|---|---|---|---|---|---|---|---|
+| Docs_Model_Docs | models/Docs.php | model | 8 | fs | P2 | ⚠️ | scan/resolve/tree/search engine; `resolve()` path-traversal guard (`_safeSegment`) + visibility filter |
+| Docs_IndexController | controllers/IndexController.php | controller | 3 | http | P2 | ⚠️ | public render by untrusted slug/locale; visibility gating + slug dispatch |
+| Docs_Reference_Generator | bin/reference.php | service | 3 | fs | P2 | ⚠️ | token docblock/signature reference gen; `_parseFile`/`_parseSignature`/`_parseDoc` + `_slug` writes |
+| Docs_Model_Index | models/Index.php | model | 3 | fs | P2 | no | scan-cache: fingerprint + atomic `var_export`/`include` (**no `unserialize`** → not P1); `_stillValid` invalidation |
+| Docs_Service_Search | services/Search.php | service | 1 | fs | P3 | no | thin search passthrough |
+| Docs_Service_Settings | services/Settings.php | service | 3 | fs | P3 | no | save/rebuild index + buildReference |
+| Docs_AdminController | controllers/AdminController.php | controller | 3 | http | P3 | no | settings + help |
+| Docs_Form_Settings | forms/Settings.php | form | 0 | pure | P4 | no | element defs |
+| Docs_Bootstrap | Bootstrap.php | plugin | 0 | db | P4 | no | route override, sitemap, nav |
+
+**TigerCodeSnippets (WebTigers/TigerCodeSnippets) — 6 pure functions (easiest wins in the whole manifest)**
+
+| unit | path | kind | test | pri | note |
+|---|---|---|---|---|---|
+| `slug` | snippets/slug.php | helper | pure | P4 | string → url slug |
+| `human_bytes` | snippets/human-bytes.php | helper | pure | P4 | bytes → human string |
+| `time_ago` | snippets/time-ago.php | helper | pure | P4 | timestamp → relative |
+| `ordinal` | snippets/ordinal.php | helper | pure | P4 | int → 1st/2nd/… |
+| `initials` | snippets/initials.php | helper | pure | P4 | name → initials |
+| `array_get` | snippets/array-get.php | helper | pure | P4 | dot-path accessor |
+
+**TigerAPIDocs — 2 classes** (both thin): `Apidocs_IndexController` (http, P3, serves Swagger + spec),
+`Apidocs_Bootstrap` (P4). **Tiger skeleton — 1**: `Bootstrap` (near-empty stub, P4).
+
+**⚠️ P1 security logic that is PROCEDURAL, not a class** (needs characterization tests now, and is a
+refactor-to-testable candidate — you can't cleanly unit a 30-function script):
+
+| file | repo | why P1 |
+|---|---|---|
+| `scripts/review.php` | TigerVendors | registry listing validator over **untrusted PR JSON + fetched GitHub content** — schema, public-repo, license, slug, tag-exists checks. The supply-chain gate. |
+| `tiger-install.php` | tiger-install | web installer: `http_download`/`rcopy`/`resolve_release`/`do_install_files` — **downloads + unpacks a release tarball**, CSRF token, DB provision, owner creation over untrusted POST |
+| `bin/tigershield.php` | TigerShield | CLI/cron CrowdSec entrypoint (refresh/status/provision/enroll) |
+| `scripts/review-ai.php` / `scripts/compile-index.php` | TigerVendors | LLM review pass (fetches untrusted repo files) + CI index compiler |
+
+**Section note:** TigerShield's 5 pure/fs decision engines (`Waf`, `Blocklist`, `Challenge`,
+`RateLimit`, `Firewall`) are the highest-value non-tiger-core tests in the whole set — a WAF that
+fails open silently is worse than none. `Tigershield_Service_Waf` is `pure` → start there.
+
+### 6.6 JS component repos — TigerUpload, TigerLightbox — 6 units
+
+**Harness prerequisite (both repos): no JS test setup exists at all** — no `package.json` test
+script, no `__tests__`/`*.test.js`, no jest/vitest/mocha. Step zero = pick a runner (**vitest +
+jsdom** recommended — ESM-friendly, fast) and add XHR/FormData/`URL.createObjectURL` mocks.
+
+| unit | path | kind | #pub | test | pri | sec | note |
+|---|---|---|---|---|---|---|---|
+| Uploader (TigerUpload) | tiger-upload.js | class | 12 | xhr | **P2** | ⚠️ | engine: queue/concurrency pump, per-file XHR progress, pub/sub `_emit`, `prepare(item,fd)` hook (sync+promise), retry/cancel/abort, `_reject` accept/maxSize validation. Riskiest: `_active` accounting on load/error/abort + async prepare send-after-`then` |
+| TigerLightbox (viewer) | tiger-lightbox.js | module | 2 | dom | **P2** | ⚠️ | IIFE state machine: `open/close`, render img/video/iframe(pdf), `go()`/swipe modulo-index nav, preload, Esc/arrow + Tab focus-trap. Riskiest: index clamp/wraparound, focus-trap Tab cycle, `close()` teardown timing; media `src` = embed/XSS surface |
+| TigerUpload.list (renderer) | tiger-upload-list.js | function | 1 | dom | P3 | ⚠️ | default renderer: subscribes → thumbnail+progress rows, remove/retry wiring. `esc()` guards name/error/ext but **`previewUrl` img src is unescaped** — XSS test |
+| lightbox auto-wire | tiger-lightbox.js | module | 0 | event | P3 | ⚠️ | delegated click on `[data-tiger-lightbox]`; `guessType(src)` from untrusted href; builds `querySelector` from group value (escapes only `"`) — **selector-injection** test |
+| tigerParse | tiger-upload.js | function | 1 | pure | P3 | no | `/api` envelope parser over fake `{status,responseText}`; status vs `result!==1` branching + firstMessage null-guard — **easy pure win** |
+| TigerUpload (facade) | tiger-upload.js | object | 1 | pure | P4 | no | `create(opts)`→Uploader export glue |
+
+---
+
+## 7. Summary counts
+
+**Everything in scope is untested** (except TigerZF, §2). Totals below are first-party units needing
+tests written from zero.
+
+| Area | Units | of which P1 (security-critical) |
+|---|---|---|
+| tiger-core — data layer (6.1) | 35 | 13 |
+| tiger-core — kernel (6.2) | 59 (+1 vendored excluded) | 17 |
+| tiger-core — media + location (6.3) | 18 | 7 |
+| tiger-core — core/ + modules/ (6.4) | 67 | ~22 (services + auth/file controllers) |
+| **tiger-core subtotal** | **179** | **~59** |
+| TigerShield (6.5) | 14 | 5 |
+| TigerDocs (6.5) | 9 | 0 (3 P2 with traversal/visibility surface) |
+| TigerCodeSnippets (6.5) | 6 | 0 (all pure — trivial wins) |
+| TigerAPIDocs + Tiger skeleton (6.5) | 3 | 0 |
+| **satellite PHP subtotal** | **32** | **5** |
+| JS — TigerUpload + TigerLightbox (6.6) | 6 | 0 (4 have XSS surface) |
+| **TOTAL first-party units** | **~217** | **~64 P1** |
+
+Plus **~5 procedural P1 files** (not classes): `TigerVendors/scripts/review.php`,
+`tiger-install/tiger-install.php`, `TigerShield/bin/tigershield.php`, `review-ai.php`,
+`compile-index.php` — characterization tests + refactor-to-testable candidates.
+Plus **~31 tiger-core migrations** — one "migrate up→down on a clean DB" integration test, not per-file.
+
+**By testability (drives harness build order):**
+- **pure** (~25) — no harness; write first for fast baseline coverage. Includes all 6 TigerCodeSnippets
+ functions, `Tigershield_Service_Waf`, `Tiger_Uuid`, `tigerParse`, the `Seo_Service_*` statics,
+ `Tiger_Cms_Renderer`, most forms.
+- **db** (~90) — the fixture-DB decision (§3) gates all of these. The bulk of the work.
+- **fs** (~35) — temp-dir sandboxes; the install/extract/code-runtime P1s live here.
+- **net** (~25) — mock/contract tests + optional cred-gated live smoke (AWS/GCS/Azure/GitHub/CrowdSec).
+- **crypto** (~6) — test key/pepper; the highest-value-per-test cluster (auth/rotation).
+- **http** (~30) — controllers + plugins; mostly dispatch/smoke.
+- **static** (~8) — reset registries between cases.
+
+## 8. Recommended authoring order (maps §5 spine → this inventory)
+
+1. **Stand up the PHPUnit harness** (§3) — bootstrap + fixture MySQL + registry-reset + test secrets.
+ *Nothing below runs without this.*
+2. **Pure quick-wins** for a fast green baseline + to prove the harness: TigerCodeSnippets ×6,
+ `Tigershield_Service_Waf`, `Tiger_Uuid`, `tigerParse`.
+3. **P1 crypto/auth cluster:** `Tiger_Security`, `Tiger_Crypto`, `Tiger_Auth_Totp`,
+ `Tiger_Model_UserCredential`, `Tiger_Service_Authentication`, `Tiger_Model_AuthChallenge`,
+ `Tiger_Policy_Password`.
+4. **P1 gateway/authz:** `Tiger_Ajax_ServiceFactory`, `Tiger_Acl_Acl`,
+ `Tiger_Controller_Plugin_Authorization`, `Tiger_Model_OrgUser`, `Tiger_Model_Table` (tenant stamp +
+ soft-delete — underpins every model).
+5. **P1 install/update/RCE:** `Tiger_Module_Installer` (zip-slip), `Tiger_Vendor`, `Tiger_Update_Core`,
+ `Tiger_Code_Runtime` + `Code_Service_Code`, `System_Service_Modules`, `Signup_Service_Signup`.
+6. **P1 storage/media:** the shared storage-adapter traversal/visibility trait ×4 + `Media_FileController`.
+7. **TigerShield P1 engines:** `Firewall`, `Blocklist`, `Challenge`, `RateLimit`.
+8. **Procedural P1 characterization tests:** `review.php`, `tiger-install.php`.
+9. **P2 substrate**, then the migration up/down integration test, then **P3 features**, then **P4 smoke**.
+10. **CI:** wire the PHP 8.1–8.5 matrix (mirror TigerZF) + the vitest/jsdom JS suite; make green the
+ 1.0 `@api`-freeze gate.
+
+---
+
+## 9. Progress log (the running backfill tracker)
+
+**2026-07-24 — harness promoted to `main` + CI (§3 DONE), then Wave 1.**
+
+- **Foundation:** `phpunit.xml` + `tests/bootstrap.php` + `Support/{Unit,Integration}TestCase` + `composer
+ test:*` + `.github/workflows/tests.yml` (unit 8.1–8.5 + integration on MariaDB), all green in CI.
+- **Baseline unioned onto main (17 files):** crypto/auth/gateway (`Acl`, `Ajax_ServiceFactory`, `Auth_Totp`,
+ `Crypto`, `Security`, `Uuid`, integration `OrgUser`+schema) + the commerce/license/agent/module set
+ (`Crypto_Signature`, `License_{Authority,Checker}`, `Module_{Pricing,Compat,Dependency,Installer-sig}`, agent).
+- **Wave 1 — 3 parallel no-DB clusters (+119 tests → 247 unit / 8 integration):**
+ - ✅ **Media** — `Storage_Filesystem` (traversal guard + public/private split), `Storage` factory,
+ `Image` (never-upscale), `Storage_S3` (`_fullKey` guard). *(§6.3)*
+ - ✅ **CMS/routing/i18n** — `Cms_Renderer` (trust-tier dispatch), `Routing_Overrides` (reserved-prefix),
+ `I18n_Country`, `Location_Place`. *(§6.2/6.3)*
+ - ✅ **Module install** — `Module_Installer` (zip-slip/tar-slip guard + slug/manifest + migrationPaths),
+ `Module_Discovery`, `Vendor` (sha256 + semver). *(§6.2)*
+
+### Findings surfaced by the tests (source unchanged — decide + address separately)
+1. **`Tiger_Cms_Renderer` markdown is NOT safe-mode** (Renderer.php ~83): `Parsedown::instance()->text()`
+ with no `setSafeMode(true)` → raw HTML / `', Tiger_Model_Page::FORMAT_MARKDOWN);
+ $this->assertStringContainsString('', $out);
+ }
+
+ #[Test]
+ public function phtml_is_executed_as_a_template_with_its_context(): void
+ {
+ // The phtml tier IS code (trusted): the body is evaluated as a Zend_View template and can
+ // read its assigned context vars.
+ $out = $this->renderer->renderBody(
+ '= 6 * 7 ?>-= $this->name ?>',
+ Tiger_Model_Page::FORMAT_PHTML,
+ ['name' => 'Thundarr']
+ );
+ $this->assertSame('42-Thundarr', $out);
+ }
+
+ #[Test]
+ public function builder_format_is_treated_as_safe_verbatim_html_plus_shortcodes(): void
+ {
+ // builder output is self-contained [year]', Tiger_Model_Page::FORMAT_BUILDER);
+ $this->assertSame('2026', $out);
+ }
+
+ #[Test]
+ public function an_unknown_format_falls_back_to_verbatim_html(): void
+ {
+ $body = '
plain
';
+ $this->assertSame($body, $this->renderer->renderBody($body, 'totally-made-up'));
+ }
+
+ #[Test]
+ public function a_registered_shortcode_is_substituted(): void
+ {
+ Tiger_Cms_Renderer::registerShortcode('greet', static fn () => 'HELLO');
+ $this->assertSame('say HELLO', $this->renderer->renderBody('say [greet]', Tiger_Model_Page::FORMAT_HTML));
+ }
+
+ #[Test]
+ public function shortcode_names_are_case_insensitive(): void
+ {
+ Tiger_Cms_Renderer::registerShortcode('Greet', static fn () => 'HI');
+ // registered mixed-case, invoked upper- and lower-case — both resolve.
+ $this->assertSame('HI/HI', $this->renderer->renderBody('[GREET]/[greet]', Tiger_Model_Page::FORMAT_HTML));
+ }
+
+ #[Test]
+ public function an_unregistered_shortcode_is_left_as_literal_text(): void
+ {
+ // No handler => the literal token is preserved (not stripped, not errored).
+ $this->assertSame('[nope]', $this->renderer->renderBody('[nope]', Tiger_Model_Page::FORMAT_HTML));
+ }
+
+ #[Test]
+ public function shortcode_attributes_are_parsed_into_the_handler(): void
+ {
+ Tiger_Cms_Renderer::registerShortcode('btn', static function (array $attrs) {
+ return $attrs['label'] . '|' . $attrs['size'];
+ });
+ $out = $this->renderer->renderBody('[btn label="Buy" size="lg"]', Tiger_Model_Page::FORMAT_HTML);
+ $this->assertSame('Buy|lg', $out);
+ }
+
+ #[Test]
+ public function a_wrapping_shortcode_receives_its_inner_content(): void
+ {
+ Tiger_Cms_Renderer::registerShortcode('upper', static function (array $attrs, ?string $inner) {
+ return strtoupper((string) $inner);
+ });
+ $out = $this->renderer->renderBody('[upper]quiet[/upper]', Tiger_Model_Page::FORMAT_HTML);
+ $this->assertSame('QUIET', $out);
+ }
+
+ #[Test]
+ public function a_self_closing_shortcode_receives_a_null_inner(): void
+ {
+ Tiger_Cms_Renderer::registerShortcode('probe', static function (array $attrs, ?string $inner) {
+ return $inner === null ? 'NULL-INNER' : 'HAS-INNER';
+ });
+ $this->assertSame('NULL-INNER', $this->renderer->renderBody('[probe]', Tiger_Model_Page::FORMAT_HTML));
+ }
+
+ #[Test]
+ public function shortcode_output_is_inserted_raw_so_the_handler_owns_escaping(): void
+ {
+ // ESCAPING POSTURE: the substitution does NOT escape a handler's return value — whatever the
+ // handler emits lands in the document as-is. That's by design (a shortcode legitimately emits
+ // markup), and it's the contract callers must respect: escape untrusted data INSIDE the
+ // handler. We pin it so the posture can't silently change.
+ Tiger_Cms_Renderer::registerShortcode('raw', static fn () => 'bold');
+ $this->assertSame('bold', $this->renderer->renderBody('[raw]', Tiger_Model_Page::FORMAT_HTML));
+ }
+
+ #[Test]
+ public function the_shortcode_context_reaches_the_handler(): void
+ {
+ // renderBody passes its $context array through to the handler as the third arg — a shortcode
+ // can read page/view state.
+ Tiger_Cms_Renderer::registerShortcode('who', static function (array $attrs, ?string $inner, array $context) {
+ return $context['user'] ?? 'anon';
+ });
+ $out = $this->renderer->renderBody('[who]', Tiger_Model_Page::FORMAT_HTML, ['user' => 'Ariel']);
+ $this->assertSame('Ariel', $out);
+ }
+
+ #[Test]
+ public function a_body_with_no_bracket_skips_the_shortcode_pass_untouched(): void
+ {
+ // Fast-path guard: no '[' => the string is returned as-is even with handlers registered.
+ Tiger_Cms_Renderer::registerShortcode('greet', static fn () => 'HELLO');
+ $this->assertSame('nothing here', $this->renderer->renderBody('nothing here', Tiger_Model_Page::FORMAT_HTML));
+ }
+}
diff --git a/tests/Unit/CryptoTest.php b/tests/Unit/CryptoTest.php
new file mode 100644
index 0000000..1aef630
--- /dev/null
+++ b/tests/Unit/CryptoTest.php
@@ -0,0 +1,134 @@
+ $key];
+ if ($retired !== null) {
+ $crypto['key_retired'] = $retired;
+ }
+ $this->setConfig(['tiger' => ['crypto' => $crypto]]);
+ }
+
+ #[Test]
+ public function encrypt_then_decrypt_round_trips(): void
+ {
+ $this->useKeys(self::KEY_A);
+ $secret = 'the TOTP shared secret';
+ $blob = Tiger_Crypto::encrypt($secret);
+
+ $this->assertNotSame($secret, $blob);
+ $this->assertNotFalse(base64_decode($blob, true), 'blob must be base64');
+ $this->assertSame($secret, Tiger_Crypto::decrypt($blob));
+ }
+
+ #[Test]
+ public function each_encryption_uses_a_fresh_nonce(): void
+ {
+ $this->useKeys(self::KEY_A);
+ $a = Tiger_Crypto::encrypt('same input');
+ $b = Tiger_Crypto::encrypt('same input');
+ $this->assertNotSame($a, $b, 'identical plaintext must not produce identical ciphertext');
+ $this->assertSame('same input', Tiger_Crypto::decrypt($a));
+ $this->assertSame('same input', Tiger_Crypto::decrypt($b));
+ }
+
+ #[Test]
+ public function decrypt_rejects_a_tampered_blob(): void
+ {
+ $this->useKeys(self::KEY_A);
+ $blob = Tiger_Crypto::encrypt('authentic');
+ $raw = base64_decode($blob, true);
+ $raw[strlen($raw) - 1] = $raw[strlen($raw) - 1] ^ "\xff"; // flip the last ciphertext byte
+ $tampered = base64_encode($raw);
+
+ $this->expectException(RuntimeException::class);
+ Tiger_Crypto::decrypt($tampered);
+ }
+
+ #[Test]
+ public function decrypt_rejects_malformed_input(): void
+ {
+ $this->useKeys(self::KEY_A);
+ $this->expectException(RuntimeException::class);
+ Tiger_Crypto::decrypt('not base64 !!! too short');
+ }
+
+ #[Test]
+ public function a_wrong_key_cannot_decrypt(): void
+ {
+ $this->useKeys(self::KEY_A);
+ $blob = Tiger_Crypto::encrypt('secret under A');
+
+ $this->useKeys(self::KEY_B); // only B configured now
+ $this->expectException(RuntimeException::class);
+ Tiger_Crypto::decrypt($blob);
+ }
+
+ #[Test]
+ public function retired_key_still_decrypts_during_a_rotation_window(): void
+ {
+ $this->useKeys(self::KEY_A);
+ $blob = Tiger_Crypto::encrypt('secret under A');
+
+ // rotate: B is current, A retired — old ciphertext must still open.
+ $this->useKeys(self::KEY_B, self::KEY_A);
+ $this->assertSame('secret under A', Tiger_Crypto::decrypt($blob));
+
+ // reencrypt moves it under the CURRENT key; then A alone must NOT decrypt it.
+ $rekeyed = Tiger_Crypto::reencrypt($blob);
+ $this->useKeys(self::KEY_B);
+ $this->assertSame('secret under A', Tiger_Crypto::decrypt($rekeyed));
+ $this->useKeys(self::KEY_A);
+ $this->expectException(RuntimeException::class);
+ Tiger_Crypto::decrypt($rekeyed);
+ }
+
+ #[Test]
+ public function isConfigured_reflects_key_presence(): void
+ {
+ $this->assertFalse(Tiger_Crypto::isConfigured(), 'no config => not configured');
+ $this->useKeys(self::KEY_A);
+ $this->assertTrue(Tiger_Crypto::isConfigured());
+ }
+
+ #[Test]
+ public function encrypt_without_a_key_is_a_hard_error(): void
+ {
+ // No config at all — encryption must throw, never silently no-op.
+ $this->expectException(RuntimeException::class);
+ Tiger_Crypto::encrypt('anything');
+ }
+
+ #[Test]
+ public function generateKey_mints_a_usable_32_byte_key(): void
+ {
+ $key = Tiger_Crypto::generateKey();
+ $raw = base64_decode($key, true);
+ $this->assertNotFalse($raw);
+ $this->assertSame(32, strlen($raw));
+
+ $this->useKeys($key);
+ $this->assertSame('works', Tiger_Crypto::decrypt(Tiger_Crypto::encrypt('works')));
+ }
+}
diff --git a/tests/Unit/I18n/CountryTest.php b/tests/Unit/I18n/CountryTest.php
new file mode 100644
index 0000000..2918125
--- /dev/null
+++ b/tests/Unit/I18n/CountryTest.php
@@ -0,0 +1,167 @@
+ alpha-3 lookup (case-insensitive, empty/unknown -> null), name
+ * resolution with the translation-override-then-CLDR fallback, and the BIAS SORT — the six flagged
+ * "common" countries (sort>0) float to the top of the picker in sort-weight order, everything else A–Z
+ * by localized name. We always pass an explicit `en` locale so assertions are deterministic regardless
+ * of the host's default locale.
+ *
+ * No DB — the class is a static reader over a shipped ini + CLDR (Zend_Locale, offline). The `$_data`
+ * cache is reset via reflection each test for hygiene; the translator lives in the registry that
+ * UnitTestCase isolates per test.
+ */
+#[CoversClass(Tiger_I18n_Country::class)]
+final class CountryTest extends UnitTestCase
+{
+ /** The biased "common" set, in ascending sort-weight order (US=1, CA=2, GB=3, AU=4, NZ=5, IE=6). */
+ private const COMMON = ['US', 'CA', 'GB', 'AU', 'NZ', 'IE'];
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->resetData();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->resetData();
+ parent::tearDown();
+ }
+
+ /** Reset the lazy static ini cache so a test that seeded a translator can't affect a later read. */
+ private function resetData(): void
+ {
+ $prop = new ReflectionProperty(Tiger_I18n_Country::class, '_data');
+ $prop->setValue(null, null); // reflection reaches the private static without setAccessible (PHP 8.1+)
+ }
+
+ #[Test]
+ public function iso3_resolves_alpha3_case_insensitively(): void
+ {
+ $this->assertSame('USA', Tiger_I18n_Country::iso3('US'));
+ $this->assertSame('USA', Tiger_I18n_Country::iso3('us'), 'input is upper-cased');
+ $this->assertSame('GBR', Tiger_I18n_Country::iso3('GB'));
+ }
+
+ #[Test]
+ public function iso3_is_null_for_an_unknown_or_empty_code(): void
+ {
+ $this->assertNull(Tiger_I18n_Country::iso3('QX'), 'unknown code');
+ // AC (Ascension Island) ships with an EMPTY iso3 in the ini — empty must normalize to null,
+ // not the empty string.
+ $this->assertNull(Tiger_I18n_Country::iso3('AC'), 'empty iso3 => null');
+ }
+
+ #[Test]
+ public function codes_lists_every_alpha2_in_the_data_file(): void
+ {
+ $codes = Tiger_I18n_Country::codes();
+ $this->assertContains('US', $codes);
+ $this->assertContains('GB', $codes);
+ $this->assertGreaterThan(200, count($codes), 'the ISO-3166 set is ~250 entries');
+ }
+
+ #[Test]
+ public function name_resolves_via_cldr_when_no_translation_override_exists(): void
+ {
+ $this->assertSame('United States', Tiger_I18n_Country::name('US', 'en'));
+ $this->assertSame('United States', Tiger_I18n_Country::name('us', 'en'), 'code is upper-cased');
+ }
+
+ #[Test]
+ public function name_falls_back_to_the_code_when_wholly_unresolved(): void
+ {
+ // QX is neither in a translator nor in CLDR — the last resort is the code itself.
+ $this->assertSame('QX', Tiger_I18n_Country::name('QX', 'en'));
+ }
+
+ #[Test]
+ public function a_translation_override_beats_cldr(): void
+ {
+ // An install can override a country name via the `country.` translation key; that wins
+ // over CLDR's authoritative name.
+ $t = new Zend_Translate([
+ 'adapter' => 'array',
+ 'content' => ['country.US' => 'The States'],
+ 'locale' => 'en',
+ ]);
+ Zend_Registry::set('Zend_Translate', $t);
+
+ $this->assertSame('The States', Tiger_I18n_Country::name('US', 'en'));
+ // an un-overridden country still comes from CLDR.
+ $this->assertSame('Canada', Tiger_I18n_Country::name('CA', 'en'));
+ }
+
+ #[Test]
+ public function all_floats_the_common_countries_to_the_top_in_sort_weight_order(): void
+ {
+ $keys = array_keys(Tiger_I18n_Country::all('en'));
+ $this->assertSame(self::COMMON, array_slice($keys, 0, 6), 'biased block leads, in weight order');
+ }
+
+ #[Test]
+ public function all_orders_the_remainder_alphabetically_by_localized_name(): void
+ {
+ $all = Tiger_I18n_Country::all('en');
+ $rest = array_slice($all, 6, null, true); // everything after the common block
+ $names = array_values($rest);
+
+ $sorted = $names;
+ usort($sorted, 'strcasecmp');
+ $this->assertSame($sorted, $names, 'the non-common tail is A–Z by name');
+ }
+
+ #[Test]
+ public function all_returns_the_full_set_localized(): void
+ {
+ $all = Tiger_I18n_Country::all('en');
+ $this->assertSame(count(Tiger_I18n_Country::codes()), count($all));
+ $this->assertSame('United States', $all['US']);
+ }
+
+ #[Test]
+ public function grouped_splits_the_biased_block_from_the_rest(): void
+ {
+ $grouped = Tiger_I18n_Country::grouped('en');
+ $this->assertSame(self::COMMON, array_keys($grouped['priority']));
+ $this->assertArrayNotHasKey('US', $grouped['rest'], 'a common country is not also in the rest');
+ // the two blocks partition the whole set with no overlap or loss.
+ $this->assertSame(
+ count(Tiger_I18n_Country::codes()),
+ count($grouped['priority']) + count($grouped['rest'])
+ );
+ }
+
+ #[Test]
+ public function options_builds_a_placeholder_then_common_then_all_countries_optgroups(): void
+ {
+ $opts = Tiger_I18n_Country::options('en', '— Pick one —');
+
+ // leading empty-value placeholder.
+ $this->assertArrayHasKey('', $opts);
+ $this->assertSame('— Pick one —', $opts['']);
+
+ // the "Common" optgroup carries exactly the biased six.
+ $this->assertArrayHasKey('Common', $opts);
+ $this->assertSame(self::COMMON, array_keys($opts['Common']));
+
+ // and the rest live under "All countries".
+ $this->assertArrayHasKey('All countries', $opts);
+ $this->assertArrayNotHasKey('US', $opts['All countries']);
+ }
+}
diff --git a/tests/Unit/Location/PlaceTest.php b/tests/Unit/Location/PlaceTest.php
new file mode 100644
index 0000000..8000a67
--- /dev/null
+++ b/tests/Unit/Location/PlaceTest.php
@@ -0,0 +1,130 @@
+ 'osm:123',
+ 'label' => '1 Infinite Loop, Cupertino, CA',
+ 'line1' => '1 Infinite Loop',
+ 'city' => 'Cupertino',
+ 'region' => 'CA',
+ 'postal' => '95014',
+ 'country' => 'US',
+ 'latitude' => 37.3318,
+ 'longitude' => -122.0312,
+ 'type' => 'address',
+ 'source' => 'nominatim',
+ ]);
+
+ $this->assertSame('osm:123', $place->id);
+ $this->assertSame('Cupertino', $place->city);
+ $this->assertSame('US', $place->country);
+ $this->assertSame(37.3318, $place->latitude);
+ $this->assertSame('nominatim', $place->source);
+ }
+
+ #[Test]
+ public function the_constructor_ignores_unknown_keys(): void
+ {
+ // A provider dumping extra keys can't create dynamic properties on the value object — only
+ // the declared schema is populated.
+ $place = new Tiger_Location_Place([
+ 'city' => 'Paris',
+ 'bogus' => 'x',
+ 'admin_sql' => 'DROP TABLE',
+ ]);
+
+ $this->assertSame('Paris', $place->city);
+ $this->assertArrayNotHasKey('bogus', $place->toArray());
+ $this->assertArrayNotHasKey('admin_sql', $place->toArray());
+ }
+
+ #[Test]
+ public function an_empty_construction_leaves_nulls_and_an_empty_raw(): void
+ {
+ $place = new Tiger_Location_Place();
+ $this->assertNull($place->city);
+ $this->assertNull($place->country);
+ $this->assertSame([], $place->raw, 'raw defaults to an empty array, never null');
+ }
+
+ #[Test]
+ public function raw_is_stored_on_the_object_but_dropped_from_the_public_payload(): void
+ {
+ // `raw` is the provider-specific debug blob — kept on the object for server-side use, but the
+ // public contract (toArray) must NOT expose it.
+ $place = new Tiger_Location_Place([
+ 'city' => 'Berlin',
+ 'raw' => ['provider' => 'nominatim', 'osm_id' => 999, 'place_rank' => 30],
+ ]);
+
+ $this->assertSame(['provider' => 'nominatim', 'osm_id' => 999, 'place_rank' => 30], $place->raw);
+ $this->assertArrayNotHasKey('raw', $place->toArray());
+ }
+
+ #[Test]
+ public function to_array_exposes_exactly_the_stable_field_set(): void
+ {
+ $place = new Tiger_Location_Place(['city' => 'Tokyo', 'raw' => ['x' => 1]]);
+
+ $this->assertSame(
+ ['id', 'label', 'line1', 'line2', 'city', 'region', 'postal', 'country',
+ 'latitude', 'longitude', 'type', 'source', 'ip'],
+ array_keys($place->toArray())
+ );
+ }
+
+ #[Test]
+ public function to_array_round_trips_normalized_field_values(): void
+ {
+ $data = [
+ 'id' => 'p1',
+ 'label' => 'Somewhere',
+ 'line1' => '10 Downing St',
+ 'line2' => 'Flat 2',
+ 'city' => 'London',
+ 'region' => 'England',
+ 'postal' => 'SW1A 2AA',
+ 'country' => 'GB',
+ 'latitude' => 51.5033,
+ 'longitude' => -0.1276,
+ 'type' => 'address',
+ 'source' => 'nominatim',
+ 'ip' => null,
+ ];
+
+ $this->assertSame($data, (new Tiger_Location_Place($data))->toArray());
+ }
+
+ #[Test]
+ public function an_ip_lookup_place_carries_its_ip(): void
+ {
+ // The IP geolocation path populates `ip`, which IS part of the public payload (unlike raw).
+ $place = new Tiger_Location_Place(['type' => 'ip', 'country' => 'US', 'ip' => '203.0.113.7']);
+ $out = $place->toArray();
+ $this->assertSame('203.0.113.7', $out['ip']);
+ $this->assertSame('ip', $out['type']);
+ }
+}
diff --git a/tests/Unit/Media/FilesystemTest.php b/tests/Unit/Media/FilesystemTest.php
new file mode 100644
index 0000000..6871d57
--- /dev/null
+++ b/tests/Unit/Media/FilesystemTest.php
@@ -0,0 +1,262 @@
+sandbox = sys_get_temp_dir() . '/tiger-fs-' . bin2hex(random_bytes(6));
+ $this->publicRoot = $this->sandbox . '/public/_media';
+ $this->privateRoot = $this->sandbox . '/storage/media';
+ $this->outside = $this->sandbox . '/OUTSIDE'; // sibling of both roots
+ mkdir($this->publicRoot, 0777, true);
+ mkdir($this->privateRoot, 0777, true);
+ mkdir($this->outside, 0777, true);
+ }
+
+ protected function tearDown(): void
+ {
+ $this->rmrf($this->sandbox);
+ parent::tearDown();
+ }
+
+ /** Build an adapter pointed at the sandbox roots (absolute → used verbatim). */
+ private function adapter(): Tiger_Media_Storage_Filesystem
+ {
+ return new Tiger_Media_Storage_Filesystem([
+ 'public_root' => $this->publicRoot,
+ 'private_root' => $this->privateRoot,
+ 'public_url' => self::PUBLIC_URL,
+ ]);
+ }
+
+ /** Write a scratch source file (the `put()` copy-from) and return its path. */
+ private function sourceFile(string $bytes): string
+ {
+ $p = $this->sandbox . '/src-' . bin2hex(random_bytes(4)) . '.bin';
+ file_put_contents($p, $bytes);
+ return $p;
+ }
+
+ /** Recursive delete for the sandbox teardown. */
+ private function rmrf(string $dir): void
+ {
+ if (!is_dir($dir)) {
+ @unlink($dir);
+ return;
+ }
+ foreach (scandir($dir) as $e) {
+ if ($e === '.' || $e === '..') {
+ continue;
+ }
+ $path = $dir . '/' . $e;
+ is_dir($path) && !is_link($path) ? $this->rmrf($path) : @unlink($path);
+ }
+ @rmdir($dir);
+ }
+
+ // ---- Roundtrip -----------------------------------------------------------------------
+
+ #[Test]
+ public function put_get_exists_size_delete_round_trips_for_public(): void
+ {
+ $fs = $this->adapter();
+ $key = 'org-1/images/photo.jpg';
+ $body = 'the public bytes';
+ $src = $this->sourceFile($body);
+
+ $this->assertFalse($fs->exists($key, 'public'), 'nothing there yet');
+ $this->assertSame(0, $fs->size($key, 'public'), 'missing size is 0, not an error');
+
+ $fs->put($key, $src, 'public', 'image/jpeg');
+
+ $this->assertTrue($fs->exists($key, 'public'));
+ $this->assertSame($body, $fs->get($key, 'public'));
+ $this->assertSame(strlen($body), $fs->size($key, 'public'));
+ // The bytes landed UNDER the public root, nowhere else.
+ $this->assertFileExists($this->publicRoot . '/' . $key);
+
+ $fs->delete($key, 'public');
+ $this->assertFalse($fs->exists($key, 'public'));
+ }
+
+ #[Test]
+ public function write_get_delete_round_trips_for_private(): void
+ {
+ $fs = $this->adapter();
+ $key = 'org-2/files/book.epub';
+ $body = 'the private bytes';
+
+ $fs->write($key, $body, 'private', 'application/epub+zip');
+
+ $this->assertTrue($fs->exists($key, 'private'));
+ $this->assertSame($body, $fs->get($key, 'private'));
+ $this->assertSame(strlen($body), $fs->size($key, 'private'));
+ // Private bytes live OUTSIDE the docroot (under the private root), never under public.
+ $this->assertFileExists($this->privateRoot . '/' . $key);
+ $this->assertFileDoesNotExist($this->publicRoot . '/' . $key);
+
+ $fs->delete($key, 'private');
+ $this->assertFalse($fs->exists($key, 'private'));
+ }
+
+ #[Test]
+ public function delete_is_idempotent_and_get_missing_throws(): void
+ {
+ $fs = $this->adapter();
+ // Deleting a key that was never written is a no-op, never an error.
+ $fs->delete('never/written.txt', 'public');
+ $this->assertFalse($fs->exists('never/written.txt', 'public'));
+
+ $this->expectException(RuntimeException::class);
+ $fs->get('never/written.txt', 'public');
+ }
+
+ // ---- Public vs private root split ---------------------------------------------------
+
+ #[Test]
+ public function public_url_is_a_docroot_url_private_url_is_empty(): void
+ {
+ $fs = $this->adapter();
+ $key = 'org-1/images/photo.jpg';
+
+ // Public → a real, directly-servable docroot URL (public_url + the key).
+ $this->assertSame(self::PUBLIC_URL . '/' . $key, $fs->url($key, 'public'));
+ // Private → '' — the media layer must stream it through the ACL-checked route.
+ $this->assertSame('', $fs->url($key, 'private'));
+ }
+
+ #[Test]
+ public function public_url_does_not_double_slash_a_leading_slash_key(): void
+ {
+ // url() ltrim()s the key so a stray leading slash can't produce '//' in the URL.
+ $this->assertSame(self::PUBLIC_URL . '/a/b.png', $this->adapter()->url('/a/b.png', 'public'));
+ }
+
+ // ---- The traversal guard (the P1 security surface) ----------------------------------
+
+ /**
+ * Every one of these keys must be REFUSED (they all carry `..`). We assert the throw AND
+ * that the escape target file was never created — a guard that threw but still wrote would
+ * be a false sense of safety.
+ *
+ * @return array
+ */
+ public static function traversalKeys(): array
+ {
+ return [
+ 'parent then sibling' => ['../OUTSIDE/escaped.txt'],
+ 'deep climb to /etc' => ['../../../../../../etc/passwd'],
+ 'nested mid-path climb' => ['org-1/images/../../../OUTSIDE/escaped.txt'],
+ 'backslash-encoded climb' => ['..\\..\\OUTSIDE\\escaped.txt'],
+ 'trailing dot-dot' => ['org-1/..'],
+ ];
+ }
+
+ #[Test]
+ #[\PHPUnit\Framework\Attributes\DataProvider('traversalKeys')]
+ public function put_refuses_a_traversal_key_and_writes_nothing_outside_the_root(string $evilKey): void
+ {
+ $fs = $this->adapter();
+ $src = $this->sourceFile('malicious');
+ $escaped = $this->outside . '/escaped.txt';
+
+ try {
+ $fs->put($evilKey, $src, 'private');
+ $this->fail("traversal key was accepted: {$evilKey}");
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('invalid key', $e->getMessage());
+ }
+ // The load-bearing assertion: nothing escaped the sandbox root.
+ $this->assertFileDoesNotExist($escaped, 'a traversal put wrote outside the configured root');
+ }
+
+ #[Test]
+ public function write_and_get_and_exists_all_refuse_a_traversal_key(): void
+ {
+ $fs = $this->adapter();
+ // The guard sits in _path(), so it fires on EVERY locating call, read or write.
+ foreach (['write', 'get', 'exists', 'size', 'delete'] as $op) {
+ try {
+ match ($op) {
+ 'write' => $fs->write('../OUTSIDE/x.txt', 'bytes', 'private'),
+ 'get' => $fs->get('../OUTSIDE/x.txt', 'private'),
+ 'exists' => $fs->exists('../OUTSIDE/x.txt', 'private'),
+ 'size' => $fs->size('../OUTSIDE/x.txt', 'private'),
+ 'delete' => $fs->delete('../OUTSIDE/x.txt', 'private'),
+ };
+ $this->fail("op '{$op}' accepted a traversal key");
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('invalid key', $e->getMessage());
+ }
+ }
+ $this->assertFileDoesNotExist($this->outside . '/x.txt');
+ }
+
+ #[Test]
+ public function an_empty_key_is_refused(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->adapter()->write('', 'bytes', 'public');
+ }
+
+ #[Test]
+ public function a_leading_slash_is_neutralized_and_stays_inside_the_root(): void
+ {
+ // '/etc/passwd' is NOT a traversal (no '..'); the adapter ltrim()s the slash so it
+ // resolves UNDER the root — it must never touch the real absolute /etc/passwd.
+ $fs = $this->adapter();
+ $fs->write('/etc/passwd', 'not the real one', 'private');
+
+ $this->assertTrue($fs->exists('/etc/passwd', 'private'));
+ $this->assertFileExists($this->privateRoot . '/etc/passwd');
+ // Sanity: the key with and without the leading slash address the same object.
+ $this->assertTrue($fs->exists('etc/passwd', 'private'));
+ }
+
+ #[Test]
+ public function backslashes_are_normalized_to_forward_slashes(): void
+ {
+ // A Windows-style separator is folded to '/', so the key nests normally (not a literal
+ // 'sub\file.txt' filename) — and, combined with the '..' check, can't be a climb.
+ $fs = $this->adapter();
+ $fs->write('sub\\dir\\file.txt', 'bytes', 'public');
+
+ $this->assertFileExists($this->publicRoot . '/sub/dir/file.txt');
+ $this->assertTrue($fs->exists('sub/dir/file.txt', 'public'));
+ }
+}
diff --git a/tests/Unit/Media/ImageTest.php b/tests/Unit/Media/ImageTest.php
new file mode 100644
index 0000000..f1c3925
--- /dev/null
+++ b/tests/Unit/Media/ImageTest.php
@@ -0,0 +1,179 @@
+markTestSkipped('GD (with PNG support) is not available in this PHP build.');
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ foreach ($this->tmp as $p) {
+ @unlink($p);
+ }
+ parent::tearDown();
+ }
+
+ /** Synthesize a real w×h PNG on disk and return its path (tracked for cleanup). */
+ private function pngSource(int $w, int $h): string
+ {
+ $img = imagecreatetruecolor($w, $h);
+ imagefilledrectangle($img, 0, 0, $w - 1, $h - 1, imagecolorallocate($img, 10, 120, 200));
+ $path = tempnam(sys_get_temp_dir(), 'tsrc') . '.png';
+ imagepng($img, $path);
+ // (no imagedestroy — it's a deprecated no-op since PHP 8.0; GC reclaims the GdImage)
+ $this->tmp[] = $path;
+ return $path;
+ }
+
+ /** Track any variant temp files the class emitted so tearDown removes them. */
+ private function trackVariants(array $variants): array
+ {
+ foreach ($variants as $v) {
+ if (isset($v['path'])) {
+ $this->tmp[] = $v['path'];
+ }
+ }
+ return $variants;
+ }
+
+ // ---- Never upscale ------------------------------------------------------------------
+
+ #[Test]
+ public function a_preset_larger_than_the_source_is_skipped_never_upscaled(): void
+ {
+ // Source longest edge = 100. A 500px preset would be an upscale → skipped; a 50px one runs.
+ $src = $this->pngSource(100, 80);
+ $out = $this->trackVariants(
+ Tiger_Media_Image::variants($src, 'image/png', ['big' => 500, 'thumb' => 50])
+ );
+
+ $this->assertArrayNotHasKey('big', $out, 'a preset >= the source longest edge must not be produced');
+ $this->assertArrayHasKey('thumb', $out);
+ // 50/100 = 0.5 → 100×80 becomes 50×40. Never larger than the source in either dimension.
+ $this->assertSame(50, $out['thumb']['width']);
+ $this->assertSame(40, $out['thumb']['height']);
+ $this->assertLessThanOrEqual(100, $out['thumb']['width']);
+ $this->assertLessThanOrEqual(80, $out['thumb']['height']);
+ }
+
+ #[Test]
+ public function a_preset_equal_to_the_source_longest_edge_is_skipped(): void
+ {
+ // edge >= long is the skip condition (>=, not >): a 100px preset on a 100px source is
+ // the original, so it's not re-emitted.
+ $src = $this->pngSource(100, 100);
+ $out = $this->trackVariants(Tiger_Media_Image::variants($src, 'image/png', ['same' => 100]));
+ $this->assertArrayNotHasKey('same', $out);
+ $this->assertSame([], $out);
+ }
+
+ // ---- Contain (never crop) fit math --------------------------------------------------
+
+ #[Test]
+ public function contain_scales_the_longest_edge_and_keeps_aspect_ratio(): void
+ {
+ // 200×100 (long edge = 200). edge=100 → scale 0.5 → 100×50 (aspect preserved, contained).
+ $src = $this->pngSource(200, 100);
+ $out = $this->trackVariants(Tiger_Media_Image::variants($src, 'image/png', ['m' => 100]));
+
+ $this->assertArrayHasKey('m', $out);
+ $this->assertSame(100, $out['m']['width'], 'longest edge scales to the preset');
+ $this->assertSame(50, $out['m']['height'], 'the short edge follows the aspect ratio');
+ $this->assertSame('image/png', $out['m']['mime']);
+ $this->assertFileExists($out['m']['path']);
+ }
+
+ #[Test]
+ public function contain_uses_the_taller_edge_when_the_image_is_portrait(): void
+ {
+ // 80×160 (long edge = 160, vertical). edge=80 → scale 0.5 → 40×80 — the HEIGHT is the
+ // constrained longest edge, proving it's max(w,h), not just width.
+ $src = $this->pngSource(80, 160);
+ $out = $this->trackVariants(Tiger_Media_Image::variants($src, 'image/png', ['p' => 80]));
+
+ $this->assertArrayHasKey('p', $out);
+ $this->assertSame(40, $out['p']['width']);
+ $this->assertSame(80, $out['p']['height']);
+ }
+
+ // ---- The MIME → GD-type map ---------------------------------------------------------
+
+ #[Test]
+ public function supports_recognizes_the_core_image_mimes(): void
+ {
+ // PNG is guaranteed (the suite skips otherwise); it exercises the map's happy path.
+ $this->assertTrue(Tiger_Media_Image::supports('image/png'));
+ }
+
+ #[Test]
+ public function supports_treats_the_jpeg_aliases_identically(): void
+ {
+ // image/jpg and image/pjpeg both map to the 'jpeg' GD type — so they can't diverge from
+ // the canonical image/jpeg answer for a given GD build.
+ $canonical = Tiger_Media_Image::supports('image/jpeg');
+ $this->assertSame($canonical, Tiger_Media_Image::supports('image/jpg'));
+ $this->assertSame($canonical, Tiger_Media_Image::supports('image/pjpeg'));
+ }
+
+ #[Test]
+ public function supports_rejects_a_mime_the_map_does_not_know(): void
+ {
+ $this->assertFalse(Tiger_Media_Image::supports('application/pdf'));
+ $this->assertFalse(Tiger_Media_Image::supports('image/svg+xml')); // vector — not a GD raster type
+ $this->assertFalse(Tiger_Media_Image::supports(''));
+ }
+
+ #[Test]
+ public function variants_returns_empty_for_an_unsupported_mime(): void
+ {
+ // Even with a real file on disk, an unsupported MIME short-circuits to [] (no error).
+ $src = $this->pngSource(200, 100);
+ $this->assertSame([], Tiger_Media_Image::variants($src, 'application/zip', ['thumb' => 50]));
+ }
+
+ #[Test]
+ public function a_zero_or_negative_preset_edge_is_ignored(): void
+ {
+ // Guard against a bad config: edge <= 0 is skipped alongside the upscale guard.
+ $src = $this->pngSource(200, 100);
+ $out = $this->trackVariants(
+ Tiger_Media_Image::variants($src, 'image/png', ['zero' => 0, 'neg' => -10, 'ok' => 50])
+ );
+ $this->assertArrayNotHasKey('zero', $out);
+ $this->assertArrayNotHasKey('neg', $out);
+ $this->assertArrayHasKey('ok', $out);
+ }
+}
diff --git a/tests/Unit/Media/S3Test.php b/tests/Unit/Media/S3Test.php
new file mode 100644
index 0000000..9de5398
--- /dev/null
+++ b/tests/Unit/Media/S3Test.php
@@ -0,0 +1,76 @@
+ 'my-bucket', 'region' => 'us-east-1'] + $overrides);
+ }
+
+ /** Reach the protected key-mapper without a live client. */
+ private function fullKey(Tiger_Media_Storage_S3 $s3, string $key, string $visibility): string
+ {
+ // setAccessible() is a no-op since PHP 8.1 — protected methods are reflectable directly.
+ $m = new ReflectionMethod($s3, '_fullKey');
+ return $m->invoke($s3, $key, $visibility);
+ }
+
+ #[Test]
+ public function full_key_rejects_a_traversal_key(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/invalid key/i');
+ $this->fullKey($this->adapter(), '../../etc/passwd', 'private');
+ }
+
+ #[Test]
+ public function full_key_maps_visibility_to_the_expected_prefix(): void
+ {
+ // Defaults: public_prefix 'public/', private_prefix 'private/', no base prefix.
+ $s3 = $this->adapter();
+ $this->assertSame('public/org-1/images/a.jpg', $this->fullKey($s3, 'org-1/images/a.jpg', 'public'));
+ $this->assertSame('private/org-1/files/b.epub', $this->fullKey($s3, 'org-1/files/b.epub', 'private'));
+ }
+
+ #[Test]
+ public function full_key_honors_a_base_prefix_and_normalizes_it(): void
+ {
+ // A base prefix is normalized to 'segment/' and prepended ahead of the visibility prefix.
+ $s3 = $this->adapter(['prefix' => 'tenant', 'public_prefix' => 'pub', 'private_prefix' => 'priv']);
+ $this->assertSame('tenant/pub/x.png', $this->fullKey($s3, 'x.png', 'public'));
+ $this->assertSame('tenant/priv/x.png', $this->fullKey($s3, 'x.png', 'private'));
+ }
+
+ #[Test]
+ public function a_missing_bucket_is_a_construct_time_error(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/bucket is required/i');
+ new Tiger_Media_Storage_S3(['region' => 'us-east-1']); // no bucket
+ }
+}
diff --git a/tests/Unit/Media/StorageTest.php b/tests/Unit/Media/StorageTest.php
new file mode 100644
index 0000000..535dd1e
--- /dev/null
+++ b/tests/Unit/Media/StorageTest.php
@@ -0,0 +1,151 @@
+setConfig(['media' => $media]);
+ }
+
+ /** A minimal filesystem disk spec (roots are never touched at construct time). */
+ private function fsDisk(): array
+ {
+ return ['adapter' => 'filesystem', 'public_root' => '/tmp/pub', 'private_root' => '/tmp/priv'];
+ }
+
+ // ---- Resolution + aliases -----------------------------------------------------------
+
+ #[Test]
+ public function resolves_a_filesystem_disk_to_the_filesystem_adapter(): void
+ {
+ $this->seedMedia(['default_disk' => 'local', 'disks' => ['local' => $this->fsDisk()]]);
+ $this->assertInstanceOf(Tiger_Media_Storage_Filesystem::class, Tiger_Media_Storage::disk('local'));
+ }
+
+ #[Test]
+ public function the_local_adapter_alias_maps_to_filesystem_too(): void
+ {
+ // 'local' and 'filesystem' are aliases for the same backend.
+ $this->seedMedia(['disks' => ['d' => ['adapter' => 'local', 'public_root' => '/tmp/p', 'private_root' => '/tmp/q']]]);
+ $this->assertInstanceOf(Tiger_Media_Storage_Filesystem::class, Tiger_Media_Storage::disk('d'));
+ }
+
+ #[Test]
+ public function resolves_an_s3_disk_to_the_s3_adapter(): void
+ {
+ // Construct-only: the S3 client (and the AWS SDK) are lazy, so no network/credentials
+ // are touched here — this just proves the factory's adapter switch reaches S3.
+ $this->seedMedia(['disks' => ['cloud' => ['adapter' => 's3', 'bucket' => 'my-bucket', 'region' => 'us-east-1']]]);
+ $this->assertInstanceOf(Tiger_Media_Storage_S3::class, Tiger_Media_Storage::disk('cloud'));
+ }
+
+ // ---- Memoization --------------------------------------------------------------------
+
+ #[Test]
+ public function the_same_disk_name_returns_the_identical_memoized_instance(): void
+ {
+ $this->seedMedia(['disks' => ['local' => $this->fsDisk()]]);
+ $a = Tiger_Media_Storage::disk('local');
+ $b = Tiger_Media_Storage::disk('local');
+ $this->assertSame($a, $b, 'disk() must memoize per name, not rebuild');
+ }
+
+ #[Test]
+ public function reset_clears_the_memo_so_the_next_call_rebuilds(): void
+ {
+ $this->seedMedia(['disks' => ['local' => $this->fsDisk()]]);
+ $a = Tiger_Media_Storage::disk('local');
+ Tiger_Media_Storage::reset();
+ $b = Tiger_Media_Storage::disk('local');
+ $this->assertNotSame($a, $b, 'after reset() a fresh instance must be built');
+ }
+
+ // ---- default_disk fallback ----------------------------------------------------------
+
+ #[Test]
+ public function disk_null_resolves_through_the_configured_default_disk(): void
+ {
+ $this->seedMedia(['default_disk' => 'local', 'disks' => ['local' => $this->fsDisk()]]);
+ // A null name means "the default disk" — and it memoizes to the SAME instance as the
+ // explicit name it resolves to.
+ $this->assertSame(Tiger_Media_Storage::disk('local'), Tiger_Media_Storage::disk(null));
+ }
+
+ #[Test]
+ public function default_disk_reads_the_configured_value(): void
+ {
+ $this->seedMedia(['default_disk' => 'assets', 'disks' => ['assets' => $this->fsDisk()]]);
+ $this->assertSame('assets', Tiger_Media_Storage::defaultDisk());
+ }
+
+ #[Test]
+ public function default_disk_falls_back_to_local_when_unset(): void
+ {
+ // media config present but no default_disk key.
+ $this->seedMedia(['disks' => ['whatever' => $this->fsDisk()]]);
+ $this->assertSame('local', Tiger_Media_Storage::defaultDisk());
+ }
+
+ #[Test]
+ public function default_disk_falls_back_to_local_with_no_media_config_at_all(): void
+ {
+ // No Zend_Config in the registry at all (base setUp unset it) — still a safe 'local'.
+ $this->assertSame('local', Tiger_Media_Storage::defaultDisk());
+ }
+
+ // ---- The two hard failures ----------------------------------------------------------
+
+ #[Test]
+ public function an_unknown_adapter_name_throws(): void
+ {
+ $this->seedMedia(['disks' => ['weird' => ['adapter' => 'quantum-blob']]]);
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/unknown adapter/i');
+ Tiger_Media_Storage::disk('weird');
+ }
+
+ #[Test]
+ public function a_disk_with_no_config_throws(): void
+ {
+ $this->seedMedia(['disks' => ['local' => $this->fsDisk()]]);
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/no config for disk/i');
+ Tiger_Media_Storage::disk('does-not-exist');
+ }
+}
diff --git a/tests/Unit/Module/DiscoveryTest.php b/tests/Unit/Module/DiscoveryTest.php
new file mode 100644
index 0000000..e29f455
--- /dev/null
+++ b/tests/Unit/Module/DiscoveryTest.php
@@ -0,0 +1,244 @@
+tmp = sys_get_temp_dir() . '/tiger_discovery_' . getmypid() . '_' . bin2hex(random_bytes(4));
+ @mkdir($this->tmp, 0775, true);
+ }
+
+ protected function tearDown(): void
+ {
+ foreach ($this->created as $dir) { $this->rrmdir($dir); }
+ // Prune the fixture scaffolding we may have minted (rmdir only removes them if now empty, so a
+ // real app dir is never touched) — keeps the working tree clean between runs.
+ @rmdir(APPLICATION_PATH . '/modules');
+ @rmdir(APPLICATION_PATH);
+ @rmdir(dirname(APPLICATION_PATH));
+ $this->rrmdir($this->tmp);
+ parent::tearDown();
+ }
+
+ /** Plant a fixture module dir under the app modules root and register it for cleanup. */
+ private function plantAppModule(string $slug, array $files): string
+ {
+ $dir = APPLICATION_PATH . '/modules/' . $slug;
+ @mkdir($dir, 0775, true);
+ foreach ($files as $rel => $contents) {
+ $full = $dir . '/' . $rel;
+ @mkdir(dirname($full), 0775, true);
+ file_put_contents($full, $contents);
+ }
+ $this->created[] = $dir;
+ return $dir;
+ }
+
+ #[Test]
+ public function detectsARoutedModuleByItsBootstrap(): void
+ {
+ $this->plantAppModule('fixrouted', [
+ 'Bootstrap.php' => " json_encode(['slug' => 'fixrouted', 'name' => 'Fix Routed', 'version' => '1.0.0']),
+ ]);
+
+ $all = Tiger_Module_Discovery::all();
+ $this->assertArrayHasKey('fixrouted', $all);
+ $this->assertSame('module', $all['fixrouted']['type']);
+ $this->assertSame('app', $all['fixrouted']['area']);
+ $this->assertSame('Fix Routed', $all['fixrouted']['name']);
+ $this->assertSame('1.0.0', $all['fixrouted']['version']);
+ $this->assertTrue($all['fixrouted']['has_manifest']);
+ }
+
+ #[Test]
+ public function detectsARoutedModuleWithControllersButNoManifest(): void
+ {
+ // A controllers/ dir alone is enough to be a routed module — no module.json required.
+ $this->plantAppModule('fixctrl', [
+ 'controllers/IndexController.php' => "assertArrayHasKey('fixctrl', $all);
+ $this->assertSame('module', $all['fixctrl']['type']);
+ $this->assertFalse($all['fixctrl']['has_manifest'], 'no manifest was shipped');
+ $this->assertSame('Fixctrl', $all['fixctrl']['name'], 'name falls back to ucfirst(slug)');
+ }
+
+ #[Test]
+ public function detectsAThemeByThemeJson(): void
+ {
+ $this->plantAppModule('theme-fixaurora', [
+ 'theme.json' => json_encode(['key' => 'fixaurora', 'name' => 'Fix Aurora', 'assetBase' => '/_fixaurora']),
+ ]);
+
+ $all = Tiger_Module_Discovery::all();
+ $this->assertArrayHasKey('theme-fixaurora', $all);
+ $this->assertSame('theme', $all['theme-fixaurora']['type']);
+ $this->assertSame('fixaurora', $all['theme-fixaurora']['key'], 'the theme key drives tiger.theme resolution');
+ $this->assertSame('/_fixaurora', $all['theme-fixaurora']['asset_base']);
+ }
+
+ #[Test]
+ public function themeKeyFallsBackToTheSlugMinusThemePrefix(): void
+ {
+ // A theme.json with no explicit key: the key is derived from the dir name (theme-).
+ $this->plantAppModule('theme-fixlumen', [
+ 'theme.json' => json_encode(['name' => 'Fix Lumen']),
+ ]);
+
+ $all = Tiger_Module_Discovery::all();
+ $this->assertArrayHasKey('theme-fixlumen', $all);
+ $this->assertSame('theme', $all['theme-fixlumen']['type']);
+ $this->assertSame('fixlumen', $all['theme-fixlumen']['key']);
+ }
+
+ #[Test]
+ public function detectsACodeModuleBySnippetsDir(): void
+ {
+ $this->plantAppModule('fixcode', [
+ 'module.json' => json_encode(['slug' => 'fixcode', 'name' => 'Fix Code']),
+ 'snippets/slug.php' => "assertArrayHasKey('fixcode', $all);
+ $this->assertSame('code', $all['fixcode']['type'], 'a snippets/ dir makes it a code module');
+ }
+
+ #[Test]
+ public function anExplicitManifestTypeOverridesTheDetectedDefault(): void
+ {
+ // `type` in the manifest wins over the capability default — it's just a label.
+ $this->plantAppModule('fixlabeled', [
+ 'module.json' => json_encode(['slug' => 'fixlabeled', 'name' => 'Fix Labeled', 'type' => 'plugin']),
+ 'Bootstrap.php' => "assertSame('plugin', $all['fixlabeled']['type']);
+ }
+
+ #[Test]
+ public function appDirWinsOnASlugCollisionWithACoreModule(): void
+ {
+ // `agent` is a real first-party core module; an app module of the same slug must shadow it.
+ $this->assertTrue(is_dir(TIGER_CORE_PATH . '/modules/agent'), 'precondition: a core "agent" module exists');
+ $this->plantAppModule('agent', [
+ 'module.json' => json_encode(['slug' => 'agent', 'name' => 'App Agent Override']),
+ ]);
+
+ $all = Tiger_Module_Discovery::all();
+ $this->assertArrayHasKey('agent', $all);
+ $this->assertSame('app', $all['agent']['area'], 'the app dir must win the collision');
+ $this->assertSame('App Agent Override', $all['agent']['name']);
+ }
+
+ #[Test]
+ public function requiresAndCompatPassThroughFromTheManifest(): void
+ {
+ // Advisory compatibility metadata is carried verbatim for Tiger_Module_Compat to interpret later.
+ $this->plantAppModule('fixcompat', [
+ 'module.json' => json_encode([
+ 'slug' => 'fixcompat', 'name' => 'Fix Compat',
+ 'requires' => ['tiger' => '0.40.0-beta', 'php' => '>=8.1'],
+ 'compat' => ['tiger' => ['min' => '0.36.0-beta', 'max' => '0.40.0-beta']],
+ ]),
+ 'Bootstrap.php' => "assertSame(['tiger' => '0.40.0-beta', 'php' => '>=8.1'], $row['requires']);
+ $this->assertSame(['tiger' => ['min' => '0.36.0-beta', 'max' => '0.40.0-beta']], $row['compat']);
+ }
+
+ #[Test]
+ public function aBareDirWithNoModuleSignalsIsSkipped(): void
+ {
+ // A dir that is neither routed, a theme, nor carries a module.json is not a module at all.
+ $this->plantAppModule('fixjunk', ['README.md' => "not a module\n"]);
+
+ $all = Tiger_Module_Discovery::all();
+ $this->assertArrayNotHasKey('fixjunk', $all);
+ }
+
+ // ---- _manifest (pure JSON parse) -------------------------------------------
+
+ #[Test]
+ public function manifestParsesValidJsonAndToleratesGarbage(): void
+ {
+ // Valid module.json → the decoded array.
+ $good = $this->tmp . '/good';
+ @mkdir($good, 0775, true);
+ file_put_contents($good . '/module.json', json_encode(['slug' => 'x', 'name' => 'X']));
+ $this->assertSame(['slug' => 'x', 'name' => 'X'], DiscoveryProbe::manifest($good));
+
+ // module.json is preferred over a same-dir theme.json.
+ $both = $this->tmp . '/both';
+ @mkdir($both, 0775, true);
+ file_put_contents($both . '/module.json', json_encode(['slug' => 'm']));
+ file_put_contents($both . '/theme.json', json_encode(['key' => 't']));
+ $this->assertSame(['slug' => 'm'], DiscoveryProbe::manifest($both));
+
+ // Malformed JSON degrades to [] (never a fatal), and a manifest-less dir returns [].
+ $broken = $this->tmp . '/broken';
+ @mkdir($broken, 0775, true);
+ file_put_contents($broken . '/module.json', '{ not json ');
+ $this->assertSame([], DiscoveryProbe::manifest($broken));
+
+ $bare = $this->tmp . '/bare';
+ @mkdir($bare, 0775, true);
+ $this->assertSame([], DiscoveryProbe::manifest($bare));
+ }
+
+ private function rrmdir(string $dir): void
+ {
+ if (!is_dir($dir)) { return; }
+ foreach (scandir($dir) ?: [] as $item) {
+ if ($item === '.' || $item === '..') { continue; }
+ $p = $dir . '/' . $item;
+ (is_dir($p) && !is_link($p)) ? $this->rrmdir($p) : @unlink($p);
+ }
+ @rmdir($dir);
+ }
+}
+
+/** Test seam: expose Discovery's protected manifest reader. */
+final class DiscoveryProbe extends Tiger_Module_Discovery
+{
+ public static function manifest(string $dir): array
+ {
+ return self::_manifest($dir);
+ }
+}
diff --git a/tests/Unit/Module/InstallerExtractTest.php b/tests/Unit/Module/InstallerExtractTest.php
new file mode 100644
index 0000000..55a960c
--- /dev/null
+++ b/tests/Unit/Module/InstallerExtractTest.php
@@ -0,0 +1,185 @@
+sandbox = sys_get_temp_dir() . '/tiger_extract_' . getmypid() . '_' . bin2hex(random_bytes(4));
+ @mkdir($this->sandbox, 0775, true);
+ $this->absProbe = sys_get_temp_dir() . '/tiger_slip_abs_' . getmypid() . '_' . bin2hex(random_bytes(4)) . '.txt';
+ @unlink($this->absProbe);
+ }
+
+ protected function tearDown(): void
+ {
+ $this->rrmdir($this->sandbox);
+ @unlink($this->absProbe);
+ parent::tearDown();
+ }
+
+ #[Test]
+ public function zipEntriesCannotEscapeTheTargetDir(): void
+ {
+ // A ZIP is what an ADMIN UPLOAD arrives as; _extract routes on the "PK" magic to the zip branch.
+ $target = $this->sandbox . '/zip_target';
+ @mkdir($target, 0775, true);
+
+ $zipPath = $this->sandbox . '/evil.zip';
+ $zip = new \ZipArchive();
+ $this->assertTrue($zip->open($zipPath, \ZipArchive::CREATE) === true, 'could not create the test zip');
+ $zip->addFromString('../escaped_rel.txt', 'PWNED'); // one level up
+ $zip->addFromString('a/b/../../../escaped_deep.txt', 'PWNED'); // deep traversal
+ $zip->addFromString($this->absProbe, 'PWNED'); // absolute path
+ $zip->addFromString('good.txt', 'legit'); // a benign control member
+ $zip->close();
+
+ InstallerExtractProbe::extract($zipPath, $target);
+
+ // The security invariant: nothing landed OUTSIDE the target — not the parent, not the
+ // grandparent, not the absolute path the archive named.
+ $this->assertFileDoesNotExist($this->sandbox . '/escaped_rel.txt', 'a ../ member escaped one level');
+ $this->assertFileDoesNotExist($this->sandbox . '/escaped_deep.txt', 'a deep ../ member escaped');
+ $this->assertFileDoesNotExist(dirname($this->sandbox) . '/escaped_rel.txt', 'a member escaped to the grandparent');
+ $this->assertFileDoesNotExist($this->absProbe, 'an absolute-path member escaped to /tmp');
+
+ // And the benign member was still extracted (the extractor worked, it just neutralized traversal).
+ $this->assertFileExists($target . '/good.txt');
+ $this->assertSame('legit', file_get_contents($target . '/good.txt'));
+
+ // Every real file the extractor produced is confined within the target subtree.
+ $this->assertAllFilesWithin($target);
+ }
+
+ #[Test]
+ public function tarEntriesCannotEscapeTheTargetDir(): void
+ {
+ // A TAR.GZ is what a GITHUB RELEASE download arrives as; _extract drives PharData for it. We
+ // hand-build the ustar bytes so the malicious names survive into the archive (PharData would
+ // sanitize them if we added them via its API), forcing the EXTRACT side to be the thing tested.
+ $target = $this->sandbox . '/tar_target';
+ @mkdir($target, 0775, true);
+
+ $tarGz = $this->sandbox . '/evil.tar.gz';
+ $this->writeTarGz($tarGz, [
+ '../escaped_rel.txt' => 'PWNED',
+ 'a/b/../../../escaped_deep.txt' => 'PWNED',
+ 'good.txt' => 'legit',
+ ]);
+
+ InstallerExtractProbe::extract($tarGz, $target);
+
+ $this->assertFileDoesNotExist($this->sandbox . '/escaped_rel.txt', 'a ../ tar member escaped one level');
+ $this->assertFileDoesNotExist($this->sandbox . '/escaped_deep.txt', 'a deep ../ tar member escaped');
+ $this->assertFileDoesNotExist(dirname($this->sandbox) . '/escaped_rel.txt', 'a tar member escaped to the grandparent');
+
+ $this->assertFileExists($target . '/good.txt');
+ $this->assertSame('legit', file_get_contents($target . '/good.txt'));
+ $this->assertAllFilesWithin($target);
+ }
+
+ // ---- helpers ---------------------------------------------------------------
+
+ /** Assert that every regular file under $root has a real path that stays inside $root (no escape). */
+ private function assertAllFilesWithin(string $root): void
+ {
+ $realRoot = realpath($root);
+ $this->assertNotFalse($realRoot);
+ $it = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
+ );
+ foreach ($it as $file) {
+ $rp = realpath($file->getPathname());
+ $this->assertNotFalse($rp);
+ $this->assertStringStartsWith($realRoot . DIRECTORY_SEPARATOR, $rp, "extracted file escaped the target: {$rp}");
+ }
+ }
+
+ /** Build a gzip'd ustar tar at $path from [name => bytes], keeping malicious names verbatim. */
+ private function writeTarGz(string $path, array $entries): void
+ {
+ $tar = '';
+ foreach ($entries as $name => $data) {
+ $tar .= $this->ustarHeader((string) $name, strlen((string) $data));
+ $tar .= $data . str_repeat("\0", (512 - strlen((string) $data) % 512) % 512);
+ }
+ $tar .= str_repeat("\0", 1024); // two zero blocks terminate the archive
+ file_put_contents($path, (string) gzencode($tar));
+ }
+
+ /** A single 512-byte ustar header with a correct checksum (name kept exactly as given). */
+ private function ustarHeader(string $name, int $size): string
+ {
+ $h = str_pad(substr($name, 0, 100), 100, "\0");
+ $h .= str_pad('0000644', 7, '0', STR_PAD_LEFT) . "\0"; // mode
+ $h .= str_pad('0000000', 7, '0', STR_PAD_LEFT) . "\0"; // uid
+ $h .= str_pad('0000000', 7, '0', STR_PAD_LEFT) . "\0"; // gid
+ $h .= str_pad(decoct($size), 11, '0', STR_PAD_LEFT) . "\0"; // size (octal)
+ $h .= str_pad(decoct(time()), 11, '0', STR_PAD_LEFT) . "\0";// mtime (octal)
+ $h .= str_repeat(' ', 8); // checksum field (spaces while summing)
+ $h .= '0'; // typeflag: regular file
+ $h .= str_repeat("\0", 100); // linkname
+ $h .= "ustar\0" . '00'; // magic + version
+ $h .= str_repeat("\0", 32) . str_repeat("\0", 32); // uname + gname
+ $h .= str_repeat("\0", 8) . str_repeat("\0", 8); // devmajor + devminor
+ $h .= str_repeat("\0", 155); // prefix
+ $h .= str_repeat("\0", 12); // pad to 512
+
+ $sum = 0;
+ for ($i = 0; $i < 512; $i++) { $sum += ord($h[$i]); }
+ $chk = str_pad(decoct($sum), 6, '0', STR_PAD_LEFT) . "\0 ";
+ return substr($h, 0, 148) . $chk . substr($h, 156);
+ }
+
+ private function rrmdir(string $dir): void
+ {
+ if (!is_dir($dir)) { return; }
+ foreach (scandir($dir) ?: [] as $item) {
+ if ($item === '.' || $item === '..') { continue; }
+ $p = $dir . '/' . $item;
+ (is_dir($p) && !is_link($p)) ? $this->rrmdir($p) : @unlink($p);
+ }
+ @rmdir($dir);
+ }
+}
+
+/** Test seam: expose the installer's protected archive extractor. */
+final class InstallerExtractProbe extends Tiger_Module_Installer
+{
+ public static function extract(string $archive, string $into): void
+ {
+ self::_extract($archive, $into);
+ }
+}
diff --git a/tests/Unit/Module/InstallerManifestTest.php b/tests/Unit/Module/InstallerManifestTest.php
new file mode 100644
index 0000000..b222e96
--- /dev/null
+++ b/tests/Unit/Module/InstallerManifestTest.php
@@ -0,0 +1,245 @@
+sandbox = sys_get_temp_dir() . '/tiger_manifest_' . getmypid() . '_' . bin2hex(random_bytes(4));
+ @mkdir($this->sandbox, 0775, true);
+ }
+
+ protected function tearDown(): void
+ {
+ $this->rrmdir($this->sandbox);
+ parent::tearDown();
+ }
+
+ // ---- _validSlug ------------------------------------------------------------
+
+ #[Test]
+ public function validSlugsAreAccepted(): void
+ {
+ // Must start with [a-z0-9], then up to 63 more of [a-z0-9_-]; input is lower-cased + trimmed first.
+ $this->assertSame('a', InstallerManifestProbe::validSlug('a'));
+ $this->assertSame('billing', InstallerManifestProbe::validSlug('billing'));
+ $this->assertSame('theme-aurora', InstallerManifestProbe::validSlug('theme-aurora'));
+ $this->assertSame('code_utils-2', InstallerManifestProbe::validSlug('code_utils-2'));
+ $this->assertSame('42day', InstallerManifestProbe::validSlug('42day')); // may start with a digit
+ $this->assertSame('billing', InstallerManifestProbe::validSlug(' Billing ')); // trimmed + lower-cased
+ $this->assertSame('a' . str_repeat('b', 63), InstallerManifestProbe::validSlug('a' . str_repeat('b', 63))); // 64 = max
+ }
+
+ #[Test]
+ public function reservedSlugsAreRejected(): void
+ {
+ // Every platform namespace is blocked so a package can never install "over the platform".
+ foreach (Tiger_Module_Installer::RESERVED as $reserved) {
+ try {
+ InstallerManifestProbe::validSlug($reserved);
+ $this->fail("reserved slug '{$reserved}' should have thrown");
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('Reserved', $e->getMessage());
+ }
+ }
+ }
+
+ #[Test]
+ public function malformedAndTraversalSlugsAreRejected(): void
+ {
+ // Anything outside the charset — including path-traversal shapes — is an "Invalid" hard error.
+ $bad = [
+ '', // empty
+ '-lead', // must not start with - or _
+ '_lead',
+ 'has space', // whitespace mid-slug
+ 'UP.PER', // dot is not in the charset
+ '../evil', // traversal
+ 'foo/bar', // slash
+ 'foo\\bar', // backslash
+ 'a' . str_repeat('b', 64), // 65 chars — one past the {0,63} tail
+ 'tab' . "\t" . 'x',
+ ];
+ foreach ($bad as $slug) {
+ try {
+ InstallerManifestProbe::validSlug($slug);
+ $this->fail('malformed slug should have thrown: ' . var_export($slug, true));
+ } catch (RuntimeException $e) {
+ $this->assertStringContainsString('Invalid', $e->getMessage());
+ }
+ }
+ }
+
+ // ---- _readManifest ---------------------------------------------------------
+
+ #[Test]
+ public function readsAModuleJsonManifest(): void
+ {
+ $dir = $this->sandbox . '/mod';
+ @mkdir($dir, 0775, true);
+ file_put_contents($dir . '/module.json', json_encode([
+ 'slug' => 'billing', 'name' => 'Billing', 'version' => '1.2.3',
+ ]));
+
+ $m = InstallerManifestProbe::readManifest($dir);
+ $this->assertIsArray($m);
+ $this->assertSame('billing', $m['slug']);
+ $this->assertSame('Billing', $m['name']);
+ $this->assertSame('1.2.3', $m['version']);
+ }
+
+ #[Test]
+ public function moduleJsonWithoutASlugIsRejected(): void
+ {
+ $dir = $this->sandbox . '/noslug';
+ @mkdir($dir, 0775, true);
+ file_put_contents($dir . '/module.json', json_encode(['name' => 'Anon']));
+ $this->assertNull(InstallerManifestProbe::readManifest($dir), 'a manifest with no slug is not installable');
+ }
+
+ #[Test]
+ public function invalidJsonManifestIsRejected(): void
+ {
+ $dir = $this->sandbox . '/broken';
+ @mkdir($dir, 0775, true);
+ file_put_contents($dir . '/module.json', '{ this is not json ');
+ $this->assertNull(InstallerManifestProbe::readManifest($dir));
+ }
+
+ #[Test]
+ public function themeJsonIsNormalizedToTheModuleShape(): void
+ {
+ // A theme installs through the SAME path as a module: theme.json → slug "theme-", type=theme.
+ $dir = $this->sandbox . '/theme';
+ @mkdir($dir, 0775, true);
+ file_put_contents($dir . '/theme.json', json_encode([
+ 'key' => 'aurora', 'name' => 'Aurora', 'version' => '0.4.0', 'license' => 'MIT',
+ 'requires' => ['php' => '>=8.1'],
+ ]));
+
+ $m = InstallerManifestProbe::readManifest($dir);
+ $this->assertIsArray($m);
+ $this->assertSame('theme-aurora', $m['slug'], 'the theme key becomes a theme- slug');
+ $this->assertSame('theme', $m['type']);
+ $this->assertSame('Aurora', $m['name']);
+ $this->assertSame('0.4.0', $m['version']);
+ $this->assertSame('MIT', $m['license']);
+ $this->assertSame(['php' => '>=8.1'], $m['requires']);
+ }
+
+ #[Test]
+ public function themeJsonWithoutAKeyIsRejected(): void
+ {
+ $dir = $this->sandbox . '/keyless';
+ @mkdir($dir, 0775, true);
+ file_put_contents($dir . '/theme.json', json_encode(['name' => 'Keyless']));
+ $this->assertNull(InstallerManifestProbe::readManifest($dir), 'a theme with no key has no slug to install under');
+ }
+
+ #[Test]
+ public function noManifestAtAllReturnsNull(): void
+ {
+ $dir = $this->sandbox . '/empty';
+ @mkdir($dir, 0775, true);
+ $this->assertNull(InstallerManifestProbe::readManifest($dir));
+ }
+
+ #[Test]
+ public function moduleJsonWinsOverThemeJsonWhenBothArePresent(): void
+ {
+ // The reader checks module.json first — a repo shipping both is treated as a code module.
+ $dir = $this->sandbox . '/both';
+ @mkdir($dir, 0775, true);
+ file_put_contents($dir . '/module.json', json_encode(['slug' => 'dual', 'name' => 'Dual']));
+ file_put_contents($dir . '/theme.json', json_encode(['key' => 'dual', 'name' => 'DualTheme']));
+
+ $m = InstallerManifestProbe::readManifest($dir);
+ $this->assertSame('dual', $m['slug']);
+ $this->assertArrayNotHasKey('type', $m, 'module.json path does not inject the theme type');
+ }
+
+ // ---- migrationPaths --------------------------------------------------------
+
+ #[Test]
+ public function migrationPathsIncludeCoreAppAndBothModuleRoots(): void
+ {
+ // bootstrap.php defines TIGER_CORE_PATH (repo root) and APPLICATION_PATH (a fixture app dir).
+ $this->assertTrue(defined('TIGER_CORE_PATH') && defined('APPLICATION_PATH'), 'path constants must be defined by the bootstrap');
+
+ $paths = Tiger_Module_Installer::migrationPaths();
+ $this->assertIsArray($paths);
+
+ // Core + app migration roots are always present, core first (precedence order).
+ $this->assertContains(TIGER_CORE_PATH . '/migrations', $paths);
+ $this->assertContains(APPLICATION_PATH . '/migrations', $paths);
+ $this->assertLessThan(
+ array_search(APPLICATION_PATH . '/migrations', $paths, true),
+ array_search(TIGER_CORE_PATH . '/migrations', $paths, true),
+ 'core migrations must precede app migrations'
+ );
+
+ // BOTH module roots are scanned: at least one first-party core module ships a migrations/ dir,
+ // and every returned per-module path sits under one of the two module roots (never elsewhere).
+ $coreModuleRoot = TIGER_CORE_PATH . '/modules';
+ $appModuleRoot = APPLICATION_PATH . '/modules';
+ $moduleMigrationDirs = array_values(array_filter($paths, static function ($p) use ($coreModuleRoot, $appModuleRoot) {
+ return strpos($p, $coreModuleRoot . '/') === 0 || strpos($p, $appModuleRoot . '/') === 0;
+ }));
+ $this->assertNotEmpty(
+ array_filter($moduleMigrationDirs, static fn ($p) => strpos($p, $coreModuleRoot . '/') === 0),
+ 'the first-party core modules root is scanned for migrations'
+ );
+ foreach ($moduleMigrationDirs as $p) {
+ $this->assertStringEndsWith('/migrations', $p, 'a per-module entry is always a migrations/ dir');
+ }
+ }
+
+ private function rrmdir(string $dir): void
+ {
+ if (!is_dir($dir)) { return; }
+ foreach (scandir($dir) ?: [] as $item) {
+ if ($item === '.' || $item === '..') { continue; }
+ $p = $dir . '/' . $item;
+ (is_dir($p) && !is_link($p)) ? $this->rrmdir($p) : @unlink($p);
+ }
+ @rmdir($dir);
+ }
+}
+
+/** Test seam: expose the installer's protected manifest/slug helpers. */
+final class InstallerManifestProbe extends Tiger_Module_Installer
+{
+ public static function validSlug(string $slug): string
+ {
+ return self::_validSlug($slug);
+ }
+
+ public static function readManifest(string $dir): ?array
+ {
+ return self::_readManifest($dir);
+ }
+}
diff --git a/tests/Unit/Routing/OverridesTest.php b/tests/Unit/Routing/OverridesTest.php
new file mode 100644
index 0000000..7bc7956
--- /dev/null
+++ b/tests/Unit/Routing/OverridesTest.php
@@ -0,0 +1,230 @@
+ 'docs',
+ 'target' => 'docs/index/docs',
+ 'priority' => 100,
+ ]);
+
+ $all = Tiger_Routing_Overrides::all();
+ $this->assertCount(1, $all);
+ $this->assertSame('docs', $all[0]['name']);
+ $this->assertSame('docs', $all[0]['prefix']);
+ $this->assertSame('docs/index/docs', $all[0]['target']);
+ $this->assertSame(['docs', 'index', 'docs'], $all[0]['mca']);
+ $this->assertTrue($all[0]['enabled']);
+ }
+
+ #[Test]
+ public function all_is_sorted_by_priority_desc_then_name_asc(): void
+ {
+ // deliberately register low-priority first, and give two the SAME priority to prove the
+ // name tie-break (ASC) kicks in.
+ Tiger_Routing_Overrides::register('low', ['pattern' => 'low', 'target' => 'low/i/i', 'priority' => 10]);
+ Tiger_Routing_Overrides::register('zebra', ['pattern' => 'zebra', 'target' => 'zebra/i/i', 'priority' => 100]);
+ Tiger_Routing_Overrides::register('alpha', ['pattern' => 'alpha', 'target' => 'alpha/i/i', 'priority' => 100]);
+
+ $order = array_map(static fn ($s) => $s['name'], Tiger_Routing_Overrides::all());
+ // 100:alpha, 100:zebra (name ASC within a priority), then 10:low
+ $this->assertSame(['alpha', 'zebra', 'low'], $order);
+ }
+
+ #[Test]
+ public function a_reserved_prefix_can_never_be_claimed_even_at_max_priority(): void
+ {
+ // The whole security point: a module aiming an override at a kernel surface is dropped from
+ // all(), so it can never shadow /api, /auth, or /admin — priority is irrelevant.
+ foreach (['api', 'auth', 'admin'] as $reserved) {
+ Tiger_Routing_Overrides::clear();
+ Tiger_Routing_Overrides::register('evil', [
+ 'pattern' => $reserved,
+ 'target' => 'evil/index/index',
+ 'priority' => 999999,
+ ]);
+ $this->assertSame([], Tiger_Routing_Overrides::all(), "prefix `$reserved` must be refused");
+ }
+ }
+
+ #[Test]
+ public function a_reserved_prefix_is_refused_by_first_path_segment_not_the_whole_string(): void
+ {
+ // `admin/anything` still heads into the reserved `admin` surface — the guard looks at the
+ // first segment, so a nested path can't sneak past it.
+ Tiger_Routing_Overrides::register('sneaky', [
+ 'pattern' => 'admin/reports',
+ 'target' => 'sneaky/index/index',
+ ]);
+ $this->assertSame([], Tiger_Routing_Overrides::all());
+ }
+
+ #[Test]
+ public function invalid_declarations_missing_a_prefix_or_target_are_dropped(): void
+ {
+ Tiger_Routing_Overrides::register('nopattern', ['pattern' => '', 'target' => 'x/y/z']);
+ Tiger_Routing_Overrides::register('notarget', ['pattern' => 'thing', 'target' => '']);
+ $this->assertSame([], Tiger_Routing_Overrides::all());
+ }
+
+ #[Test]
+ public function a_disabled_override_is_excluded_from_all_but_still_resolvable_via_get(): void
+ {
+ Tiger_Routing_Overrides::register('docs', [
+ 'pattern' => 'docs',
+ 'target' => 'docs/index/docs',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame([], Tiger_Routing_Overrides::all(), 'disabled => not walked');
+
+ // get() reports the resolved spec REGARDLESS of enabled (the admin settings screen needs it).
+ $spec = Tiger_Routing_Overrides::get('docs');
+ $this->assertNotNull($spec);
+ $this->assertFalse($spec['enabled']);
+ $this->assertSame('docs', $spec['prefix']);
+ }
+
+ #[Test]
+ public function the_prefix_is_derived_by_stripping_trailing_route_vars(): void
+ {
+ Tiger_Routing_Overrides::register('a', ['pattern' => 'docs/:slug', 'target' => 'docs/index/docs']);
+ Tiger_Routing_Overrides::register('b', ['pattern' => 'help/*', 'target' => 'docs/index/docs']);
+
+ $this->assertSame('docs', Tiger_Routing_Overrides::get('a')['prefix']);
+ $this->assertSame('help', Tiger_Routing_Overrides::get('b')['prefix']);
+ }
+
+ #[Test]
+ public function the_mca_parse_pads_a_short_target_with_index(): void
+ {
+ Tiger_Routing_Overrides::register('bare', ['pattern' => 'bare', 'target' => 'bare']);
+ Tiger_Routing_Overrides::register('two', ['pattern' => 'two', 'target' => 'shop/cart']);
+
+ $this->assertSame(['bare', 'index', 'index'], Tiger_Routing_Overrides::get('bare')['mca']);
+ $this->assertSame(['shop', 'cart', 'index'], Tiger_Routing_Overrides::get('two')['mca']);
+ }
+
+ #[Test]
+ public function the_override_name_is_normalized_to_a_safe_key(): void
+ {
+ // register() strips non-alnum/underscore and lowercases — so `My-Doc!` is stored as `mydoc`.
+ Tiger_Routing_Overrides::register('My-Doc!', ['pattern' => 'md', 'target' => 'md/i/i']);
+ $this->assertNotNull(Tiger_Routing_Overrides::get('mydoc'));
+ $this->assertSame('mydoc', Tiger_Routing_Overrides::all()[0]['name']);
+ }
+
+ #[Test]
+ public function get_returns_null_when_neither_a_declaration_nor_config_exists(): void
+ {
+ $this->assertNull(Tiger_Routing_Overrides::get('ghost'));
+ }
+
+ #[Test]
+ public function the_config_tier_retargets_the_pattern_over_the_module_default(): void
+ {
+ Tiger_Routing_Overrides::register('docs', ['pattern' => 'docs', 'target' => 'docs/index/docs']);
+
+ // admin retargets /docs -> /help via the config override tier (no deploy).
+ $config = $this->setConfig(['tiger' => ['routing' => ['override' => [
+ 'docs' => ['pattern' => 'help'],
+ ]]]]);
+
+ $spec = Tiger_Routing_Overrides::get('docs', $config);
+ $this->assertSame('help', $spec['prefix'], 'config pattern wins');
+ $this->assertSame('docs/index/docs', $spec['target'], 'untouched fields keep the default');
+
+ // and all() serves it at the new prefix.
+ $all = Tiger_Routing_Overrides::all($config);
+ $this->assertSame('help', $all[0]['prefix']);
+ }
+
+ #[Test]
+ public function the_config_tier_can_disable_a_declared_override(): void
+ {
+ Tiger_Routing_Overrides::register('docs', ['pattern' => 'docs', 'target' => 'docs/index/docs']);
+
+ // enabled comes off config as `(bool)(int)` — the string "0" must switch it OFF.
+ $config = $this->setConfig(['tiger' => ['routing' => ['override' => [
+ 'docs' => ['enabled' => '0'],
+ ]]]]);
+
+ $this->assertFalse(Tiger_Routing_Overrides::get('docs', $config)['enabled']);
+ $this->assertSame([], Tiger_Routing_Overrides::all($config));
+ }
+
+ #[Test]
+ public function the_config_tier_can_reprioritize_against_another_override(): void
+ {
+ Tiger_Routing_Overrides::register('docs', ['pattern' => 'docs', 'target' => 'docs/i/i', 'priority' => 100]);
+ Tiger_Routing_Overrides::register('shop', ['pattern' => 'shop', 'target' => 'shop/i/i', 'priority' => 200]);
+
+ // config bumps docs above shop.
+ $config = $this->setConfig(['tiger' => ['routing' => ['override' => [
+ 'docs' => ['priority' => '250'],
+ ]]]]);
+
+ $order = array_map(static fn ($s) => $s['name'], Tiger_Routing_Overrides::all($config));
+ $this->assertSame(['docs', 'shop'], $order);
+ }
+
+ #[Test]
+ public function even_a_config_only_override_still_obeys_the_reserved_guard(): void
+ {
+ // No declaration at all — the entry exists purely in config, and it aims at /auth. It must
+ // still be refused: the guard is not a property of "being declared in code".
+ $config = $this->setConfig(['tiger' => ['routing' => ['override' => [
+ 'takeover' => ['pattern' => 'auth', 'target' => 'takeover/index/index'],
+ ]]]]);
+
+ $this->assertSame([], Tiger_Routing_Overrides::all($config));
+ }
+
+ #[Test]
+ public function clear_resets_the_declared_registry(): void
+ {
+ Tiger_Routing_Overrides::register('docs', ['pattern' => 'docs', 'target' => 'docs/i/i']);
+ $this->assertCount(1, Tiger_Routing_Overrides::all());
+
+ Tiger_Routing_Overrides::clear();
+ $this->assertSame([], Tiger_Routing_Overrides::all());
+ }
+}
diff --git a/tests/Unit/SecurityTest.php b/tests/Unit/SecurityTest.php
new file mode 100644
index 0000000..fe22c44
--- /dev/null
+++ b/tests/Unit/SecurityTest.php
@@ -0,0 +1,127 @@
+ $current];
+ if ($retired !== null) {
+ $sec['pepper_retired'] = $retired;
+ }
+ $this->setConfig(['tiger' => ['security' => $sec]]);
+ }
+
+ #[Test]
+ public function without_a_pepper_prehash_is_the_raw_password(): void
+ {
+ // no config
+ $this->assertFalse(Tiger_Security::hasPepper());
+ $this->assertSame('hunter2', Tiger_Security::prehashPassword('hunter2'));
+ }
+
+ #[Test]
+ public function a_pepper_changes_the_prehash_deterministically(): void
+ {
+ $this->usePepper(self::PEP_A);
+ $this->assertTrue(Tiger_Security::hasPepper());
+
+ $one = Tiger_Security::prehashPassword('hunter2');
+ $two = Tiger_Security::prehashPassword('hunter2');
+ $this->assertSame($one, $two, 'same input+pepper must be stable');
+ $this->assertNotSame('hunter2', $one, 'pepper must transform the password');
+ // 32-byte HMAC, base64 -> 44 chars, under bcrypt's 72-byte limit
+ $this->assertSame(44, strlen($one));
+
+ // a different pepper yields a different prehash
+ $this->usePepper(self::PEP_B);
+ $this->assertNotSame($one, Tiger_Security::prehashPassword('hunter2'));
+ }
+
+ #[Test]
+ public function password_verifiers_are_ordered_current_then_retired_then_legacy(): void
+ {
+ $this->usePepper(self::PEP_A, self::PEP_B);
+ $verifiers = Tiger_Security::passwordVerifiers('hunter2');
+
+ // current pepper first…
+ $this->assertSame(Tiger_Security::prehashPassword('hunter2'), $verifiers[0]);
+ // …legacy raw password last (the no-pepper migration path)
+ $this->assertSame('hunter2', $verifiers[array_key_last($verifiers)]);
+ // current + retired + legacy = 3 candidates
+ $this->assertCount(3, $verifiers);
+ }
+
+ #[Test]
+ public function code_hash_and_match_round_trip(): void
+ {
+ $this->usePepper(self::PEP_A);
+ $hash = Tiger_Security::hashCode('123456', 'login');
+ $this->assertTrue(Tiger_Security::codeMatches('123456', 'login', $hash));
+ }
+
+ #[Test]
+ public function code_hash_is_domain_separated_by_context(): void
+ {
+ $this->usePepper(self::PEP_A);
+ $hash = Tiger_Security::hashCode('123456', 'recovery');
+ // same code, wrong context must NOT match
+ $this->assertFalse(Tiger_Security::codeMatches('123456', 'login', $hash));
+ $this->assertTrue(Tiger_Security::codeMatches('123456', 'recovery', $hash));
+ }
+
+ #[Test]
+ public function code_hash_keeps_the_legacy_sha256_shape(): void
+ {
+ $this->usePepper(self::PEP_A);
+ $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', Tiger_Security::hashCode('123456', 'login'));
+ }
+
+ #[Test]
+ public function a_code_issued_before_rotation_still_matches_via_retired_pepper(): void
+ {
+ $this->usePepper(self::PEP_A);
+ $hash = Tiger_Security::hashCode('123456', 'recovery');
+
+ // rotate: B current, A retired — the pre-rotation code must still redeem.
+ $this->usePepper(self::PEP_B, self::PEP_A);
+ $this->assertTrue(Tiger_Security::codeMatches('123456', 'recovery', $hash));
+
+ // but once A is fully dropped, it no longer matches (legacy sha256 won't either).
+ $this->usePepper(self::PEP_B);
+ $this->assertFalse(Tiger_Security::codeMatches('123456', 'recovery', $hash));
+ }
+
+ #[Test]
+ public function without_a_pepper_code_hash_is_plain_sha256(): void
+ {
+ // no config
+ $this->assertSame(hash('sha256', '123456'), Tiger_Security::hashCode('123456', 'login'));
+ $this->assertTrue(Tiger_Security::codeMatches('123456', 'login', hash('sha256', '123456')));
+ }
+
+ #[Test]
+ public function generated_pepper_is_32_bytes_base64(): void
+ {
+ $raw = base64_decode(Tiger_Security::generatePepper(), true);
+ $this->assertNotFalse($raw);
+ $this->assertSame(32, strlen($raw));
+ }
+}
diff --git a/tests/Unit/UuidTest.php b/tests/Unit/UuidTest.php
new file mode 100644
index 0000000..09d8d9b
--- /dev/null
+++ b/tests/Unit/UuidTest.php
@@ -0,0 +1,104 @@
+assertMatchesRegularExpression(self::RE_V4, $u);
+ $this->assertTrue(Tiger_Uuid::isValid($u));
+ }
+
+ #[Test]
+ public function v7_has_the_right_version_and_variant(): void
+ {
+ $u = Tiger_Uuid::v7();
+ $this->assertMatchesRegularExpression(self::RE_V7, $u);
+ $this->assertTrue(Tiger_Uuid::isValid($u));
+ }
+
+ #[Test]
+ public function generate_defaults_to_v7(): void
+ {
+ $this->assertMatchesRegularExpression(self::RE_V7, Tiger_Uuid::generate());
+ }
+
+ #[Test]
+ public function v4_values_are_unique(): void
+ {
+ $seen = [];
+ for ($i = 0; $i < 1000; $i++) {
+ $seen[Tiger_Uuid::v4()] = true;
+ }
+ $this->assertCount(1000, $seen, 'v4 collided within 1000 draws');
+ }
+
+ #[Test]
+ public function v7_is_time_ordered_at_millisecond_granularity(): void
+ {
+ // v7's guarantee is ms-granularity ordering: the embedded timestamp never goes backwards.
+ // (Within a single ms the low 10 bytes are random, so full-string order is NOT guaranteed —
+ // that's spec-correct, and exactly what this asserts instead of over-claiming.)
+ $prevTs = 0.0;
+ for ($i = 0; $i < 5000; $i++) {
+ $ts = Tiger_Uuid::timeOf(Tiger_Uuid::v7());
+ $this->assertGreaterThanOrEqual($prevTs, $ts, "v7 timestamp went backwards at draw $i");
+ $prevTs = $ts;
+ }
+ }
+
+ #[Test]
+ public function v7_generated_across_a_millisecond_boundary_sort_in_order(): void
+ {
+ $first = Tiger_Uuid::v7();
+ usleep(2000); // cross a ms boundary
+ $second = Tiger_Uuid::v7();
+ $this->assertLessThan($second, $first, 'v7 across a ms boundary must sort by creation');
+ }
+
+ #[Test]
+ public function timeof_v7_is_close_to_now(): void
+ {
+ $before = microtime(true);
+ $ts = Tiger_Uuid::timeOf(Tiger_Uuid::v7()); // float unix seconds, ms precision
+ $after = microtime(true);
+
+ $this->assertIsFloat($ts);
+ $this->assertGreaterThanOrEqual($before - 1.0, $ts);
+ $this->assertLessThanOrEqual($after + 1.0, $ts);
+ }
+
+ #[Test]
+ public function isvalid_rejects_junk(): void
+ {
+ $this->assertFalse(Tiger_Uuid::isValid(''));
+ $this->assertFalse(Tiger_Uuid::isValid('not-a-uuid'));
+ $this->assertFalse(Tiger_Uuid::isValid('zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz'));
+ // right shape, one char too long
+ $this->assertFalse(Tiger_Uuid::isValid('00000000-0000-7000-8000-0000000000000'));
+ }
+
+ #[Test]
+ public function isvalid_accepts_a_canonical_lowercase_uuid(): void
+ {
+ $this->assertTrue(Tiger_Uuid::isValid('00000000-0000-7000-8000-000000000000'));
+ }
+}
diff --git a/tests/Unit/VendorTest.php b/tests/Unit/VendorTest.php
new file mode 100644
index 0000000..62c3bd0
--- /dev/null
+++ b/tests/Unit/VendorTest.php
@@ -0,0 +1,177 @@
+tmp = sys_get_temp_dir() . '/tiger_vendor_' . getmypid() . '_' . bin2hex(random_bytes(4));
+ @mkdir($this->tmp, 0775, true);
+ $this->storePreExisted = is_dir(Tiger_Vendor_Environment::storeDir());
+ }
+
+ protected function tearDown(): void
+ {
+ // Never network-touch: only clean the local temp sandbox and any store dir WE created.
+ $store = Tiger_Vendor_Environment::storeDir();
+ if (!$this->storePreExisted && is_dir($store)) {
+ $this->rrmdir($store);
+ } else {
+ // store pre-existed — remove just our test package subdir, leave everything else alone.
+ $this->rrmdir($store . '/test-lib');
+ }
+ $this->rrmdir($this->tmp);
+ parent::tearDown();
+ }
+
+ // ---- satisfies() : the semver matcher --------------------------------------
+
+ #[Test]
+ public function caretConstraintPinsTheMajor(): void
+ {
+ $this->assertTrue(Tiger_Vendor::satisfies('1.4.0', '^1.0'));
+ $this->assertTrue(Tiger_Vendor::satisfies('1.0.0', '^1.0'));
+ $this->assertFalse(Tiger_Vendor::satisfies('2.0.0', '^1.0'), '^1 excludes the next major');
+ $this->assertFalse(Tiger_Vendor::satisfies('0.9.9', '^1.0'), 'below the floor');
+ }
+
+ #[Test]
+ public function caretBelowOnePinsTheMinor(): void
+ {
+ // ^0.3 ⇒ >=0.3.0 <0.4.0 (a 0.x caret pins the first non-zero segment).
+ $this->assertTrue(Tiger_Vendor::satisfies('0.3.5', '^0.3'));
+ $this->assertFalse(Tiger_Vendor::satisfies('0.4.0', '^0.3'));
+ }
+
+ #[Test]
+ public function tildeConstraintPinsToTheLastGivenSegment(): void
+ {
+ // ~3.2 ⇒ >=3.2.0 <4.0.0 ; ~3.2.1 ⇒ >=3.2.1 <3.3.0.
+ $this->assertTrue(Tiger_Vendor::satisfies('3.9.9', '~3.2'));
+ $this->assertFalse(Tiger_Vendor::satisfies('4.0.0', '~3.2'));
+ $this->assertTrue(Tiger_Vendor::satisfies('3.2.5', '~3.2.1'));
+ $this->assertFalse(Tiger_Vendor::satisfies('3.3.0', '~3.2.1'));
+ }
+
+ #[Test]
+ public function comparatorAndExactAndWildcardForms(): void
+ {
+ $this->assertTrue(Tiger_Vendor::satisfies('0.6.0', '>=0.5.0'));
+ $this->assertFalse(Tiger_Vendor::satisfies('0.4.0', '>=0.5.0'));
+ $this->assertTrue(Tiger_Vendor::satisfies('1.2.3', '=1.2.3'));
+ $this->assertTrue(Tiger_Vendor::satisfies('1.2.3', '==1.2.3')); // == is normalized to =
+ $this->assertFalse(Tiger_Vendor::satisfies('1.2.4', '=1.2.3'));
+ $this->assertTrue(Tiger_Vendor::satisfies('3.5.1', '3.*'));
+ $this->assertFalse(Tiger_Vendor::satisfies('4.0.0', '3.*'));
+ $this->assertTrue(Tiger_Vendor::satisfies('1.2.3', '*'), 'the wildcard matches anything');
+ }
+
+ #[Test]
+ public function andGroupsAndOrGroups(): void
+ {
+ // Space/comma = AND (all must hold); || = OR (any group holds).
+ $this->assertTrue(Tiger_Vendor::satisfies('3.5.0', '>=3.1 <4.0'));
+ $this->assertFalse(Tiger_Vendor::satisfies('4.1.0', '>=3.1 <4.0'));
+ $this->assertTrue(Tiger_Vendor::satisfies('3.5.0', '>=3.1, <4.0'));
+ $this->assertTrue(Tiger_Vendor::satisfies('2.3.0', '^1 || ^2'));
+ $this->assertFalse(Tiger_Vendor::satisfies('3.0.0', '^1 || ^2'));
+ }
+
+ #[Test]
+ public function versionPrefixIsToleratedAndEmptyConstraintMatchesNothing(): void
+ {
+ $this->assertTrue(Tiger_Vendor::satisfies('v3.0.0', '3.0.0'), 'a leading v is stripped from the version');
+ $this->assertTrue(Tiger_Vendor::satisfies('3.0.0', 'v3.0.0'), 'a leading v is stripped from the constraint');
+ // An EMPTY constraint satisfies nothing here — callers special-case "no constraint" BEFORE
+ // reaching satisfies(), so the matcher treats "" as "no group matched" (false).
+ $this->assertFalse(Tiger_Vendor::satisfies('1.0.0', ''));
+ }
+
+ // ---- installTarball() : the SHA-256 integrity gate -------------------------
+
+ #[Test]
+ public function aCorrectSha256Installs(): void
+ {
+ $tarGz = $this->makeTarGz();
+ $sha = hash_file('sha256', $tarGz);
+
+ $r = Tiger_Vendor::installTarball('file://' . $tarGz, 'test/lib', $sha);
+ $this->assertTrue($r['ok'], 'a matching hash must install: ' . ($r['message'] ?? ''));
+ $this->assertDirectoryExists(Tiger_Vendor_Environment::storeDir() . '/test-lib');
+ }
+
+ #[Test]
+ public function aWrongSha256IsRefusedAsTampering(): void
+ {
+ $tarGz = $this->makeTarGz();
+
+ $r = Tiger_Vendor::installTarball('file://' . $tarGz, 'test/lib', str_repeat('0', 64));
+ $this->assertFalse($r['ok'], 'a hash mismatch must NOT install');
+ $this->assertStringContainsString('Checksum mismatch', $r['message']);
+ // Nothing was unpacked into the store — the gate fires before the extract/place step.
+ $this->assertDirectoryDoesNotExist(Tiger_Vendor_Environment::storeDir() . '/test-lib');
+ }
+
+ #[Test]
+ public function noSha256GivenSkipsTheGateButStillInstalls(): void
+ {
+ // sha256 is optional; when omitted the download is trusted (e.g. a source tarball) and installs.
+ $tarGz = $this->makeTarGz();
+ $r = Tiger_Vendor::installTarball('file://' . $tarGz, 'test/lib', null);
+ $this->assertTrue($r['ok'], $r['message'] ?? '');
+ }
+
+ // ---- helpers ---------------------------------------------------------------
+
+ /** Build a small, valid .tar.gz in the temp sandbox and return its path. */
+ private function makeTarGz(): string
+ {
+ $base = $this->tmp . '/pkg_' . bin2hex(random_bytes(3)) . '.tar';
+ $phar = new \PharData($base);
+ $phar->addFromString('lib/Thing.php', "compress(\Phar::GZ);
+ unset($phar);
+ $gz = $base . '.gz';
+ $this->assertFileExists($gz);
+ return $gz;
+ }
+
+ private function rrmdir(string $dir): void
+ {
+ if (!is_dir($dir)) { return; }
+ foreach (scandir($dir) ?: [] as $item) {
+ if ($item === '.' || $item === '..') { continue; }
+ $p = $dir . '/' . $item;
+ (is_dir($p) && !is_link($p)) ? $this->rrmdir($p) : @unlink($p);
+ }
+ @rmdir($dir);
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..dbd0dc9
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,59 @@
+