From b869a47a3d745f8f2d8d36d2971b3cabc77d3027 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 24 Jul 2026 12:18:15 -0400 Subject: [PATCH 1/3] test: promote the PHPUnit harness to main + CI (unit + integration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness had been stranded on a local test-harness branch while 9 unit-test files accumulated dormant on main (nothing to run them, no CI). This unions the two disjoint sets onto main and makes them first-class: - Harness: phpunit.xml, tests/bootstrap.php (PSR-0 Tiger_*/Zend_* autoload, no web boot), Tiger\Tests\Support\{Unit,Integration}TestCase (registry isolation; migrate-once + env-gated self-skipping fixture DB), composer test/test:unit/test:integration scripts, require-dev phpunit. - CI: .github/workflows/tests.yml — unit matrix (PHP 8.1-8.5) + one integration job on a MariaDB service. Runs on push to main + every PR (complements version-check/smoke/release-zip). - Brings the 8 crypto/auth/gateway tests off test-harness (Acl, Ajax/ServiceFactory, AuthTotp, Crypto, Security, Uuid + 2 integration: OrgUser, schema/model). With main's existing 9 (crypto-signature/license/module/agent) that's 17 files: 128 unit + 8 integration, all green. - tests/COVERAGE-PLAN.md: the coverage manifest (refreshed) — the running backfill tracker. Foundation for the test backfill toward the 1.0 @api-freeze gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 84 +++++ composer.json | 25 +- phpunit.xml | 49 +++ tests/COVERAGE-PLAN.md | 460 +++++++++++++++++++++++ tests/Integration/Model/OrgUserTest.php | 108 ++++++ tests/Integration/SchemaAndModelTest.php | 78 ++++ tests/Support/IntegrationTestCase.php | 109 ++++++ tests/Support/UnitTestCase.php | 55 +++ tests/Unit/Acl/AclTest.php | 107 ++++++ tests/Unit/Ajax/ServiceFactoryTest.php | 205 ++++++++++ tests/Unit/AuthTotpTest.php | 111 ++++++ tests/Unit/CryptoTest.php | 134 +++++++ tests/Unit/SecurityTest.php | 127 +++++++ tests/Unit/UuidTest.php | 104 +++++ tests/bootstrap.php | 59 +++ 15 files changed, 1812 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 phpunit.xml create mode 100644 tests/COVERAGE-PLAN.md create mode 100644 tests/Integration/Model/OrgUserTest.php create mode 100644 tests/Integration/SchemaAndModelTest.php create mode 100644 tests/Support/IntegrationTestCase.php create mode 100644 tests/Support/UnitTestCase.php create mode 100644 tests/Unit/Acl/AclTest.php create mode 100644 tests/Unit/Ajax/ServiceFactoryTest.php create mode 100644 tests/Unit/AuthTotpTest.php create mode 100644 tests/Unit/CryptoTest.php create mode 100644 tests/Unit/SecurityTest.php create mode 100644 tests/Unit/UuidTest.php create mode 100644 tests/bootstrap.php 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..1d7cf85 --- /dev/null +++ b/tests/COVERAGE-PLAN.md @@ -0,0 +1,460 @@ +# 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. + +--- +*Manifest generated 2026-07-18 by 6 parallel read-only scans. Temporary working doc — relocate into +`tiger-core/` (e.g. `tests/COVERAGE-PLAN.md`) if it graduates from scratch to the real test effort.* diff --git a/tests/Integration/Model/OrgUserTest.php b/tests/Integration/Model/OrgUserTest.php new file mode 100644 index 0000000..5c5e537 --- /dev/null +++ b/tests/Integration/Model/OrgUserTest.php @@ -0,0 +1,108 @@ +orgUser = new Tiger_Model_OrgUser(); + } + + private function makeUser(string $tag): string + { + return (new Tiger_Model_User())->insert(['email' => "$tag-" . substr(Tiger_Uuid::v7(), 0, 12) . '@example.test']); + } + + private function makeOrg(string $name): string + { + return (new Tiger_Model_Org())->insert(['name' => $name, 'slug' => strtolower($name) . '-' . substr(Tiger_Uuid::v7(), 0, 8)]); + } + + #[Test] + public function role_lives_on_the_membership_so_it_differs_per_org(): void + { + $user = $this->makeUser('u'); + $orgA = $this->makeOrg('Acme'); + $orgB = $this->makeOrg('Beta'); + + $this->orgUser->insert(['org_id' => $orgA, 'user_id' => $user, 'role' => 'admin']); + $this->orgUser->insert(['org_id' => $orgB, 'user_id' => $user, 'role' => 'viewer']); + + $this->assertSame('admin', $this->orgUser->roleOf($orgA, $user), 'same user is admin in org A'); + $this->assertSame('viewer', $this->orgUser->roleOf($orgB, $user), '…and viewer in org B'); + } + + #[Test] + public function absence_of_a_membership_is_the_cross_tenant_denial(): void + { + $member = $this->makeUser('member'); + $stranger = $this->makeUser('stranger'); + $orgA = $this->makeOrg('Acme'); + $orgB = $this->makeOrg('Beta'); + + $this->orgUser->insert(['org_id' => $orgA, 'user_id' => $member, 'role' => 'admin']); + + // a user with no row in this org => null (never a leaked role) + $this->assertNull($this->orgUser->roleOf($orgA, $stranger), 'non-member gets null'); + // the member has no standing in a DIFFERENT org + $this->assertNull($this->orgUser->roleOf($orgB, $member), 'membership does not cross tenants'); + $this->assertNull($this->orgUser->membership($orgB, $member)); + $this->assertNotNull($this->orgUser->membership($orgA, $member)); + } + + #[Test] + public function a_soft_deleted_membership_revokes_the_role(): void + { + $user = $this->makeUser('u'); + $orgA = $this->makeOrg('Acme'); + $this->orgUser->insert(['org_id' => $orgA, 'user_id' => $user, 'role' => 'admin']); + $this->assertSame('admin', $this->orgUser->roleOf($orgA, $user)); + + // revoke by soft-delete — activeSelect() excludes it, so the role is gone next read. + $this->orgUser->softDelete(['org_id = ?' => $orgA, 'user_id = ?' => $user]); + $this->assertNull($this->orgUser->roleOf($orgA, $user), 'a revoked membership grants nothing'); + } + + #[Test] + public function orgsForUser_lists_only_that_users_active_memberships(): void + { + $user = $this->makeUser('u'); + $other = $this->makeUser('other'); + $orgA = $this->makeOrg('Acme'); + $orgB = $this->makeOrg('Beta'); + + $this->orgUser->insert(['org_id' => $orgA, 'user_id' => $user, 'role' => 'admin']); + $this->orgUser->insert(['org_id' => $orgB, 'user_id' => $user, 'role' => 'viewer']); + $this->orgUser->insert(['org_id' => $orgA, 'user_id' => $other, 'role' => 'admin']); + + $orgIds = []; + foreach ($this->orgUser->orgsForUser($user) as $row) { + $orgIds[] = $row->org_id; + } + sort($orgIds); + $expected = [$orgA, $orgB]; + sort($expected); + $this->assertSame($expected, $orgIds, 'exactly this user\'s two orgs, no one else\'s'); + } +} diff --git a/tests/Integration/SchemaAndModelTest.php b/tests/Integration/SchemaAndModelTest.php new file mode 100644 index 0000000..4d6579b --- /dev/null +++ b/tests/Integration/SchemaAndModelTest.php @@ -0,0 +1,78 @@ +assertTrue($this->tableExists($table), "expected core table `$table` after migrate"); + } + } + + #[Test] + public function insert_mints_a_v7_uuid_and_stamps_actor_and_org(): void + { + $actor = Tiger_Uuid::v7(); + $orgId = Tiger_Uuid::v7(); + Tiger_Model_Table::setActor($actor); + Tiger_Model_Table::setOrg($orgId); + + $org = new Tiger_Model_Org(); + $id = $org->insert(['name' => 'Acme', 'slug' => 'acme-' . substr($actor, 0, 8)]); + + $this->assertTrue(Tiger_Uuid::isValid($id), 'insert must return a valid UUID PK'); + $row = $this->db->fetchRow('SELECT * FROM org WHERE org_id = ?', [$id]); + $this->assertNotEmpty($row); + $this->assertSame($actor, $row['created_by'], 'created_by must be stamped from the actor'); + $this->assertSame('acme-' . substr($actor, 0, 8), $row['slug']); + $this->assertNotNull($row['created_at'], 'created_at must be stamped'); + $this->assertSame(0, (int) $row['deleted']); + } + + #[Test] + public function findById_excludes_soft_deleted_rows_by_default(): void + { + $org = new Tiger_Model_Org(); + $id = $org->insert(['name' => 'Temp', 'slug' => 'temp-' . substr(Tiger_Uuid::v7(), 0, 8)]); + + $this->assertNotEmpty($org->findById($id), 'a live row must be found'); + + $org->softDelete(['org_id = ?' => $id]); + $this->assertEmpty($org->findById($id), 'soft-deleted row must be hidden from the default read'); + $this->assertNotEmpty($org->findById($id, true), 'but visible when deleted rows are included'); + + $org->restore(['org_id = ?' => $id]); + $this->assertNotEmpty($org->findById($id), 'restore must bring it back'); + } + + #[Test] + public function migrate_is_idempotent(): void + { + // The harness already migrated in setUp; a second run must apply nothing. + $migrator = new Tiger_Db_Migrator($this->db, [dirname(__DIR__, 2) . '/migrations']); + $this->assertSame([], $migrator->migrate(), 'a second migrate() must be a no-op'); + } +} diff --git a/tests/Support/IntegrationTestCase.php b/tests/Support/IntegrationTestCase.php new file mode 100644 index 0000000..518d460 --- /dev/null +++ b/tests/Support/IntegrationTestCase.php @@ -0,0 +1,109 @@ +markTestSkipped('Integration DB not configured (set TIGER_TEST_DB_NAME to run).'); + } + + $this->db = self::adapter($name); + $this->ensureMigrated(); + + // Clean per-test static context on the base model, then isolate the test in a transaction. + Tiger_Model_Table::setActor(null); + Tiger_Model_Table::setOrg(''); + $this->db->beginTransaction(); + } + + protected function tearDown(): void + { + if (isset($this->db)) { + // Roll back everything the test wrote — cheap, total isolation. + try { + $this->db->rollBack(); + } catch (\Throwable $e) { + // a test that committed/closed the txn itself is fine; ignore. + } + } + Tiger_Model_Table::setActor(null); + Tiger_Model_Table::setOrg(''); + parent::tearDown(); + } + + /** The shared, migrated adapter (built once, reused across the process). */ + private static function adapter(string $name): Zend_Db_Adapter_Abstract + { + if (self::$sharedDb === null) { + self::$sharedDb = Zend_Db::factory('Pdo_Mysql', [ + 'host' => getenv('TIGER_TEST_DB_HOST') ?: '127.0.0.1', + 'port' => (int) (getenv('TIGER_TEST_DB_PORT') ?: 3306), + 'dbname' => $name, + 'username' => getenv('TIGER_TEST_DB_USER') ?: 'root', + 'password' => getenv('TIGER_TEST_DB_PASS') ?: '', + 'charset' => 'utf8mb4', + ]); + // Wire it exactly like the app bootstrap so Tiger_Model_Table just works. + Zend_Db_Table_Abstract::setDefaultAdapter(self::$sharedDb); + Zend_Registry::set('Zend_Db', self::$sharedDb); + } + return self::$sharedDb; + } + + /** Apply core migrations once per process (idempotent — re-runs skip applied versions). */ + private function ensureMigrated(): void + { + if (self::$migrated) { + return; + } + $migrator = new Tiger_Db_Migrator($this->db, [dirname(__DIR__, 2) . '/migrations']); + $migrator->migrate(); + self::$migrated = true; + } + + /** True when a table exists in the test schema — handy for schema assertions. */ + protected function tableExists(string $table): bool + { + return (int) $this->db->fetchOne( + 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?', + [$table] + ) > 0; + } +} diff --git a/tests/Support/UnitTestCase.php b/tests/Support/UnitTestCase.php new file mode 100644 index 0000000..f0a8bd8 --- /dev/null +++ b/tests/Support/UnitTestCase.php @@ -0,0 +1,55 @@ + ['crypto' => ['key' => $b64]]]`. + */ + protected function setConfig(array $data): Zend_Config + { + $config = new Zend_Config($data, true); + Zend_Registry::set('Zend_Config', $config); + return $config; + } + + /** Convenience: register a valid test crypto key + security pepper in one call. */ + protected function setCryptoConfig(?string $key = null, ?string $pepper = null): void + { + $this->setConfig([ + 'tiger' => [ + 'crypto' => ['key' => $key ?? base64_encode(str_repeat("\x11", 32))], + 'security' => ['pepper' => $pepper ?? base64_encode(str_repeat("\x22", 32))], + ], + ]); + } +} diff --git a/tests/Unit/Acl/AclTest.php b/tests/Unit/Acl/AclTest.php new file mode 100644 index 0000000..5334eb3 --- /dev/null +++ b/tests/Unit/Acl/AclTest.php @@ -0,0 +1,107 @@ +acl = new Tiger_Acl_Acl(); + } + + #[Test] + public function the_default_role_graph_is_loaded(): void + { + foreach (['guest', 'user', 'manager', 'supermanager', 'admin', 'superadmin', 'developer'] as $role) { + $this->assertTrue($this->acl->hasRole($role), "role `$role` must exist"); + } + } + + #[Test] + public function deny_by_default_refuses_a_registered_resource_with_no_matching_allow(): void + { + // A caller registers the resource, then asks — the baseline `deny * * *` refuses it. + $this->acl->addResource(new Zend_Acl_Resource('Some_Service_Unruled')); + $this->assertFalse($this->acl->isAllowed('guest', 'Some_Service_Unruled', 'anything')); + $this->assertFalse($this->acl->isAllowed('user', 'Some_Service_Unruled', 'anything')); + } + + #[Test] + public function isAllowed_throws_on_an_unregistered_resource_by_design(): void + { + // BY DESIGN: asking about a resource the ACL has never heard of is a CALLER bug, and the + // engine fails loud rather than silently denying (which would mask the mistake). Legit callers + // register their resource first (Tiger_Ajax_ServiceFactory / the Authorization plugin do). + $this->expectException(\Zend_Acl_Exception::class); + $this->acl->isAllowed('guest', 'Never_Registered_Resource', 'go'); + } + + #[Test] + public function the_developer_role_is_god_mode(): void + { + $this->acl->addResource(new Zend_Acl_Resource('Some_Service_Unruled')); + $this->assertTrue($this->acl->isAllowed('developer', 'Some_Service_Unruled', 'anything'), + 'developer has allow * * in the shipped policy'); + } + + #[Test] + public function a_scoped_allow_is_honored_and_inherited_up_the_graph(): void + { + // acl.ini: user may reach Tiger_Service_Token; guest may not; admin inherits user's grant. + $this->assertTrue($this->acl->isAllowed('user', 'Tiger_Service_Token', 'mint')); + $this->assertFalse($this->acl->isAllowed('guest', 'Tiger_Service_Token', 'mint')); + $this->assertTrue($this->acl->isAllowed('admin', 'Tiger_Service_Token', 'mint'), + 'admin descends from user, so it inherits the token allow'); + } + + #[Test] + public function an_unknown_role_is_denied_not_fatal(): void + { + // Tiger_Acl_Acl guards this (plain Zend_Acl would throw) — a typo'd role locks OUT, never in. + $this->assertFalse($this->acl->isAllowed('nonesuch-role', 'Tiger_Service_Token', 'mint')); + } + + #[Test] + public function explain_returns_a_trace_that_matches_the_decision(): void + { + $allow = $this->acl->explain('user', 'Tiger_Service_Token', 'mint'); + $this->assertTrue($allow['allowed']); + $this->assertTrue($allow['roleKnown']); + $this->assertContains('user', $allow['roleChain']); + $this->assertNotSame('', (string) $allow['reason'], 'an allow must carry a reason'); + + $deny = $this->acl->explain('guest', 'Tiger_Service_Token', 'mint'); + $this->assertFalse($deny['allowed']); + // the trace agrees with the authoritative decision + $this->assertSame($this->acl->isAllowed('guest', 'Tiger_Service_Token', 'mint'), $deny['allowed']); + } + + #[Test] + public function explain_flags_an_unknown_role(): void + { + $ex = $this->acl->explain('nonesuch-role', 'Tiger_Service_Token', 'mint'); + $this->assertFalse($ex['allowed']); + $this->assertFalse($ex['roleKnown'], 'explain must mark an unknown role'); + } +} diff --git a/tests/Unit/Ajax/ServiceFactoryTest.php b/tests/Unit/Ajax/ServiceFactoryTest.php new file mode 100644 index 0000000..2346475 --- /dev/null +++ b/tests/Unit/Ajax/ServiceFactoryTest.php @@ -0,0 +1,205 @@ +setStorage(new Zend_Auth_Storage_NonPersistent()); + + $ref = new ReflectionClass(Tiger_Ajax_ServiceFactory::class); + $prop = $ref->getProperty('_reserved'); + $this->reservedSnapshot = $prop->getValue(); + } + + protected function tearDown(): void + { + // restore the static reserved list so reserve() tests don't leak + $ref = new ReflectionClass(Tiger_Ajax_ServiceFactory::class); + $prop = $ref->getProperty('_reserved'); + $prop->setValue(null, $this->reservedSnapshot); + + Zend_Auth::getInstance()->clearIdentity(); + parent::tearDown(); + } + + // ---- helpers ------------------------------------------------------------ + + private function request(array $svc): Zend_Controller_Request_Http + { + $r = new Zend_Controller_Request_Http(); + foreach ($svc as $k => $v) { + $r->setParam($k, $v); + } + return $r; + } + + /** An in-memory ACL: guest + user roles, one resource, allow `user` the given privilege. */ + private function installAcl(string $resource = 'Account_Service_User', string $privilege = 'save'): void + { + $acl = new Zend_Acl(); + $acl->addRole(new Zend_Acl_Role('guest')); + $acl->addRole(new Zend_Acl_Role('user'), 'guest'); + $acl->addResource(new Zend_Acl_Resource($resource)); + $acl->allow('user', $resource, $privilege); + Zend_Registry::set('Zend_Acl', $acl); + } + + private function asUser(string $role = 'user'): void + { + Zend_Auth::getInstance()->getStorage()->write((object) ['role' => $role, 'user_id' => 'u1']); + } + + private function dispatch(array $svc): object + { + return (new Tiger_Ajax_ServiceFactory($this->request($svc)))->getResponse(); + } + + private function firstMessage(object $response): string + { + return $response->messages[0]->message ?? ''; + } + + // ---- reserved list ------------------------------------------------------ + + #[Test] + public function reserved_modules_are_reported(): void + { + foreach (['tiger', 'zend', 'core', 'default', 'library', 'application'] as $name) { + $this->assertTrue(Tiger_Ajax_ServiceFactory::isReserved($name), "$name must be reserved"); + $this->assertTrue(Tiger_Ajax_ServiceFactory::isReserved(strtoupper($name)), 'case-insensitive'); + } + $this->assertFalse(Tiger_Ajax_ServiceFactory::isReserved('account')); + } + + #[Test] + public function reserve_adds_a_name(): void + { + $this->assertFalse(Tiger_Ajax_ServiceFactory::isReserved('shop')); + Tiger_Ajax_ServiceFactory::reserve('shop'); + $this->assertTrue(Tiger_Ajax_ServiceFactory::isReserved('shop')); + } + + #[Test] + public function a_core_module_is_gated_by_acl_not_a_reserved_pregate(): void + { + // BY DESIGN: there is no reserved pre-gate. A `module=tiger` request is NOT short-circuited; + // it flows to the same ACL/resolution path as any module (here, no ACL => authorize fails + // open, then the bogus class fails resolution — a reserved refusal would look different). + $r = $this->dispatch(['svc_module' => 'tiger', 'svc_service' => 'nope', 'svc_action' => 'go']); + $this->assertSame(0, $r->result); + $this->assertSame('core.api.error.general', $this->firstMessage($r), 'not a reserved-module refusal'); + } + + // ---- authorization ------------------------------------------------------ + + #[Test] + public function a_guest_is_denied_with_login_required(): void + { + $this->installAcl(); + $r = $this->dispatch(['svc_module' => 'account', 'svc_service' => 'user', 'svc_action' => 'save']); + + $this->assertSame(0, $r->result); + $this->assertSame('core.api.error.login_required', $this->firstMessage($r)); + $this->assertSame(1, $r->data->login ?? null, 'guest denial signals the client to log in'); + } + + #[Test] + public function an_authenticated_role_without_a_rule_is_not_allowed(): void + { + $this->installAcl(privilege: 'save'); // user may 'save', nothing else + $this->asUser('user'); + $r = $this->dispatch(['svc_module' => 'account', 'svc_service' => 'user', 'svc_action' => 'destroy']); + + $this->assertSame(0, $r->result); + $this->assertSame('core.api.error.not_allowed', $this->firstMessage($r)); + } + + #[Test] + public function an_allowed_role_passes_authorization_then_fails_only_on_the_missing_class(): void + { + // Proves authorize() ran and PASSED (a denied call would be not_allowed/login_required). + // The class doesn't exist in the test image, so resolution fails with the generic error. + $this->installAcl(); + $this->asUser('user'); + $r = $this->dispatch(['svc_module' => 'account', 'svc_service' => 'user', 'svc_action' => 'save']); + + $this->assertSame(0, $r->result); + $this->assertSame('core.api.error.general', $this->firstMessage($r)); + } + + #[Test] + public function no_acl_registered_fails_open(): void + { + // With no ACL, authorize() fails OPEN — so the request proceeds and only the missing class + // stops it (generic error), distinct from the deny path's login_required. + $r = $this->dispatch(['svc_module' => 'account', 'svc_service' => 'user', 'svc_action' => 'save']); + $this->assertSame('core.api.error.general', $this->firstMessage($r)); + } + + // ---- sanitization ------------------------------------------------------- + + #[Test] + public function routing_segments_are_sanitized_before_becoming_a_class_name(): void + { + // The allow rule is on the CLEAN resource `Account_Service_User`. We send dirty segments with + // dots/slashes/spaces; if sanitization works they collapse to the clean class → authorize + // passes → generic (class-missing). If it DIDN'T, the resource would be a dirty string the ACL + // never allows → not_allowed. So `general` here is proof the injection chars were stripped. + $this->installAcl('Account_Service_User', 'save'); + $this->asUser('user'); + $r = $this->dispatch(['svc_module' => 'ac/co.unt', 'svc_service' => 'us..er', 'svc_action' => 'sa ve']); + + $this->assertSame('core.api.error.general', $this->firstMessage($r), 'dirty input reached the clean resource'); + } + + // ---- shape guards ------------------------------------------------------- + + #[Test] + public function a_missing_action_is_rejected(): void + { + $r = $this->dispatch(['svc_module' => 'account', 'svc_service' => 'user', 'svc_action' => '']); + $this->assertSame('core.api.error.missing_action', $this->firstMessage($r)); + } + + #[Test] + public function a_named_service_without_a_module_is_rejected(): void + { + $r = $this->dispatch(['svc_module' => '', 'svc_service' => 'user', 'svc_action' => 'save']); + $this->assertSame('core.api.error.missing_module', $this->firstMessage($r)); + } +} diff --git a/tests/Unit/AuthTotpTest.php b/tests/Unit/AuthTotpTest.php new file mode 100644 index 0000000..4086d01 --- /dev/null +++ b/tests/Unit/AuthTotpTest.php @@ -0,0 +1,111 @@ + [unix time, expected 6-digit code] */ + public static function rfcVectors(): array + { + // RFC 6238 Appendix B (SHA1) 8-digit values, taken mod 1e6 for Tiger's 6 digits. + return [ + 'T=59' => [59, '287082'], // 94287082 + 'T=1111111109' => [1111111109, '081804'], // 07081804 + 'T=1111111111' => [1111111111, '050471'], // 14050471 + 'T=1234567890' => [1234567890, '005924'], // 89005924 + 'T=2000000000' => [2000000000, '279037'], // 69279037 + 'T=20000000000' => [20000000000, '353130'], // 65353130 + ]; + } + + #[Test] + #[DataProvider('rfcVectors')] + public function codeAt_matches_the_rfc6238_vectors(int $time, string $expected): void + { + $counter = intdiv($time, Tiger_Auth_Totp::PERIOD); + $this->assertSame($expected, Tiger_Auth_Totp::codeAt(self::SEED_B32, $counter)); + } + + #[Test] + #[DataProvider('rfcVectors')] + public function verify_accepts_the_live_code_at_a_fixed_time(int $time, string $expected): void + { + $this->assertTrue(Tiger_Auth_Totp::verify(self::SEED_B32, $expected, 1, $time)); + } + + #[Test] + public function verify_tolerates_one_step_of_drift_within_the_window(): void + { + $at = 1234567890; + $prevStepCode = Tiger_Auth_Totp::codeAt(self::SEED_B32, intdiv($at, 30) - 1); + $this->assertTrue(Tiger_Auth_Totp::verify(self::SEED_B32, $prevStepCode, 1, $at)); + // …but not two steps out, with window=1 + $twoBack = Tiger_Auth_Totp::codeAt(self::SEED_B32, intdiv($at, 30) - 2); + $this->assertFalse(Tiger_Auth_Totp::verify(self::SEED_B32, $twoBack, 1, $at)); + } + + #[Test] + public function verify_rejects_a_wrong_or_malformed_code(): void + { + $this->assertFalse(Tiger_Auth_Totp::verify(self::SEED_B32, '000000', 1, 59)); + $this->assertFalse(Tiger_Auth_Totp::verify(self::SEED_B32, '12345', 1, 59)); // too short + $this->assertFalse(Tiger_Auth_Totp::verify(self::SEED_B32, 'abcdef', 1, 59)); // non-numeric + } + + #[Test] + public function generated_secret_is_usable_base32_of_the_expected_size(): void + { + $secret = Tiger_Auth_Totp::generateSecret(); + $this->assertMatchesRegularExpression('/^[A-Z2-7]+$/', $secret, 'secret must be base32'); + // 20 bytes -> 32 base32 chars + $this->assertSame(32, strlen($secret)); + // and a code computed from it verifies against itself + $code = Tiger_Auth_Totp::codeAt($secret, 42); + $this->assertTrue(Tiger_Auth_Totp::verify($secret, $code, 1, 42 * 30)); + } + + #[Test] + public function base32_round_trips_arbitrary_bytes(): void + { + foreach ([1, 5, 16, 20, 31, 64] as $len) { + $raw = random_bytes($len); + $decoded = Tiger_Auth_Totp::base32Decode(Tiger_Auth_Totp::base32Encode($raw)); + $this->assertSame($raw, $decoded, "round-trip failed at $len bytes"); + } + } + + #[Test] + public function base32_decode_ignores_spaces_and_case(): void + { + $raw = random_bytes(20); + $enc = Tiger_Auth_Totp::base32Encode($raw); + $messy = strtolower(chunk_split($enc, 4, ' ')); // lowercased, space-grouped (how UIs show it) + $this->assertSame($raw, Tiger_Auth_Totp::base32Decode($messy)); + } + + #[Test] + public function uri_is_a_well_formed_otpauth_url(): void + { + $uri = Tiger_Auth_Totp::uri(self::SEED_B32, 'user@example.com', 'Tiger'); + $this->assertStringStartsWith('otpauth://totp/', $uri); + $this->assertStringContainsString('secret=' . self::SEED_B32, $uri); + $this->assertStringContainsString('issuer=Tiger', $uri); + } +} 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/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/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..dbd0dc9 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,59 @@ + Date: Fri, 24 Jul 2026 12:34:59 -0400 Subject: [PATCH 2/3] =?UTF-8?q?test:=20Wave=201=20backfill=20=E2=80=94=20C?= =?UTF-8?q?MS/routing/i18n,=20media=20storage,=20module=20install=20(119?= =?UTF-8?q?=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three parallel no-DB unit clusters against the priority spine (COVERAGE-PLAN.md §5/§8): - Media (38): Tiger_Media_Storage_Filesystem path-traversal guard (../, ../../, backslash, leading-slash — proven no byte escapes the root) + public/private root split (private url()=''); the disk factory (memoized, default_disk fallback, unknown-adapter throw); Tiger_Media_Image never-upscale + contain math + MIME->GD map; Tiger_Media_Storage_S3 _fullKey traversal guard. - CMS/routing/i18n (49): Tiger_Routing_Overrides reserved-prefix guard (api/auth/admin unclaimable at any priority) + priority-DESC sort + config-tier override; Tiger_Cms_Renderer trust-tier dispatch (html verbatim / phtml executes / md->Parsedown) + shortcode registry; Tiger_I18n_Country bias-sort + iso lookup fallback; Tiger_Location_Place ctor whitelist. - Module install (32): Tiger_Module_Installer zip-slip/tar-slip guard (real malicious ZIP + ustar tar with ../, ../../../, absolute members — nothing escapes) + _validSlug + _readManifest + migrationPaths; Tiger_Module_Discovery capability detection + app-wins-collision + compat/requires passthrough; Tiger_Vendor sha256 gate + semver satisfies. Full unit suite green: 247 tests / 10.6k assertions (exit 0). Two follow-ups surfaced + tracked (not changed here): Tiger_Cms_Renderer markdown lacks Parsedown safe mode (raw HTML passes — current behavior pinned by test); Tiger_Media_Image has 2 no-op imagedestroy() calls (8.5 deprecation). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Unit/Cms/RendererTest.php | 203 +++++++++++++++ tests/Unit/I18n/CountryTest.php | 167 +++++++++++++ tests/Unit/Location/PlaceTest.php | 130 ++++++++++ tests/Unit/Media/FilesystemTest.php | 262 ++++++++++++++++++++ tests/Unit/Media/ImageTest.php | 179 +++++++++++++ tests/Unit/Media/S3Test.php | 76 ++++++ tests/Unit/Media/StorageTest.php | 151 +++++++++++ tests/Unit/Module/DiscoveryTest.php | 244 ++++++++++++++++++ tests/Unit/Module/InstallerExtractTest.php | 185 ++++++++++++++ tests/Unit/Module/InstallerManifestTest.php | 245 ++++++++++++++++++ tests/Unit/Routing/OverridesTest.php | 230 +++++++++++++++++ tests/Unit/VendorTest.php | 177 +++++++++++++ 12 files changed, 2249 insertions(+) create mode 100644 tests/Unit/Cms/RendererTest.php create mode 100644 tests/Unit/I18n/CountryTest.php create mode 100644 tests/Unit/Location/PlaceTest.php create mode 100644 tests/Unit/Media/FilesystemTest.php create mode 100644 tests/Unit/Media/ImageTest.php create mode 100644 tests/Unit/Media/S3Test.php create mode 100644 tests/Unit/Media/StorageTest.php create mode 100644 tests/Unit/Module/DiscoveryTest.php create mode 100644 tests/Unit/Module/InstallerExtractTest.php create mode 100644 tests/Unit/Module/InstallerManifestTest.php create mode 100644 tests/Unit/Routing/OverridesTest.php create mode 100644 tests/Unit/VendorTest.php diff --git a/tests/Unit/Cms/RendererTest.php b/tests/Unit/Cms/RendererTest.php new file mode 100644 index 0000000..22b3e69 --- /dev/null +++ b/tests/Unit/Cms/RendererTest.php @@ -0,0 +1,203 @@ + emitted verbatim; embedded PHP is NEVER executed (it's inert text). + * - `markdown` -> Parsedown, then shortcodes. + * - `phtml` -> executed as a Zend_View template (TRUSTED authors only) with context vars. + * + * We test how *Tiger* dispatches + wires these (not Parsedown's internals): that phtml executes and + * sees its context while html does not, that shortcodes substitute after the body render, and the + * escaping posture of the shortcode substitution (handler output is inserted RAW — the handler owns + * escaping). We DON'T touch render()'s layout path (it needs the DB `page` model); renderBody() is + * the pure seam and carries the behavior. + * + * The shortcode registry is a process-global static with no public reset, so we clear it via + * reflection around every test. + */ +#[CoversClass(Tiger_Cms_Renderer::class)] +final class RendererTest extends UnitTestCase +{ + private Tiger_Cms_Renderer $renderer; + + protected function setUp(): void + { + parent::setUp(); + $this->clearShortcodes(); + $this->renderer = new Tiger_Cms_Renderer(); + } + + protected function tearDown(): void + { + $this->clearShortcodes(); // never leak a registered handler into the next test + parent::tearDown(); + } + + /** Reset the private static shortcode registry so tests don't bleed into each other. */ + private function clearShortcodes(): void + { + $prop = new ReflectionProperty(Tiger_Cms_Renderer::class, '_shortcodes'); + $prop->setValue(null, []); // reflection reaches the private static without setAccessible (PHP 8.1+) + } + + #[Test] + public function html_is_emitted_verbatim(): void + { + $body = '
Hi
'; + $this->assertSame($body, $this->renderer->renderBody($body, Tiger_Model_Page::FORMAT_HTML)); + } + + #[Test] + public function html_does_not_execute_embedded_php(): void + { + // SECURITY INVARIANT: the html tier is untrusted content — a PHP short-echo string is data, + // not code. It must come back untouched, never evaluated. + $body = ''; + $this->assertSame($body, $this->renderer->renderBody($body, Tiger_Model_Page::FORMAT_HTML)); + } + + #[Test] + public function markdown_is_converted_to_html(): void + { + $out = $this->renderer->renderBody("# Title", Tiger_Model_Page::FORMAT_MARKDOWN); + $this->assertStringContainsString('

Title

', $out); + } + + #[Test] + public function markdown_passes_raw_html_through_unescaped(): void + { + // DOCUMENTS THE ACTUAL POSTURE: Tiger dispatches to Parsedown WITHOUT safe-mode / + // markup-escaping, so raw HTML embedded in a markdown body survives verbatim. See the + // "markdown safe-mode" note in the report — the class docblock advertises markdown as + // "safe", but the shipped dispatch does not enable Parsedown's safe mode. This test asserts + // the real behavior so a future hardening (turning safe mode on) is a deliberate, visible change. + $out = $this->renderer->renderBody('', 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( + '-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/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/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); + } +} From 37eaf12d8d32705532408f22ebe28fa57ec1a285 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 24 Jul 2026 12:36:27 -0400 Subject: [PATCH 3/3] =?UTF-8?q?docs(tests):=20COVERAGE-PLAN=20=C2=A79=20pr?= =?UTF-8?q?ogress=20log=20=E2=80=94=20Wave=201=20+=20findings=20+=20next?= =?UTF-8?q?=20waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/COVERAGE-PLAN.md | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/COVERAGE-PLAN.md b/tests/COVERAGE-PLAN.md index 1d7cf85..9b3f1c3 100644 --- a/tests/COVERAGE-PLAN.md +++ b/tests/COVERAGE-PLAN.md @@ -456,5 +456,43 @@ Plus **~31 tiger-core migrations** — one "migrate up→down on a clean DB" int 1.0 `@api`-freeze gate. --- -*Manifest generated 2026-07-18 by 6 parallel read-only scans. Temporary working doc — relocate into -`tiger-core/` (e.g. `tests/COVERAGE-PLAN.md`) if it graduates from scratch to the real test effort.* + +## 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 / `