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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
.DS_Store
.idea/

# Test + runtime artifacts (never committed): the PHPUnit result cache, and the code runtime's
# compiled-bundle cache + generated public assets (Tiger_Code_Runtime writes these on compile).
/.phpunit.cache/
/storage/
/public/_code/

# Third-party/commercial theme modules are their OWN (private) repos, never inside tiger-core.
# They install into the app modules dir (application/modules/theme-<name>/), not here.
/modules/theme-*/
25 changes: 24 additions & 1 deletion library/Tiger/Mail.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ class Tiger_Mail
/** @var Zend_Config|null */
protected $_config;

/**
* A process-wide transport override. When set, it wins over the config-resolved transport for
* every send() that isn't given an explicit per-call transport — mirroring Zend_Mail's default
* transport, but honored by this wrapper (which always resolves its own, so Zend's default alone
* would never apply). Null in production (config resolution is used); the test bootstrap points it
* at a capturing Zend_Mail_Transport_File so tests never attempt real delivery.
*
* @var Zend_Mail_Transport_Abstract|null
*/
protected static $_defaultTransport = null;

/**
* Set (or clear) the process-wide default transport used when send() gets no explicit transport.
*
* @param Zend_Mail_Transport_Abstract|null $transport the transport to use, or null to clear it
* @return void
*/
public static function setDefaultTransport($transport = null)
{
self::$_defaultTransport = $transport;
}

/**
* Capture the resolved config for later transport resolution.
*
Expand Down Expand Up @@ -126,7 +148,8 @@ public function send($transport = null)
$mail->setBodyText((string) $this->_text);
}

$mail->send($transport ?: $this->transport());
// Precedence: an explicit per-call transport, else the process default (tests), else config.
$mail->send($transport ?: (self::$_defaultTransport ?: $this->transport()));
return $this;
}

Expand Down
25 changes: 18 additions & 7 deletions modules/signup/services/Signup.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,24 @@ public function create(array $params): void
(new Tiger_Model_OrgContact())->insert(['org_id' => $orgId, 'contact_id' => $contactId, 'is_primary' => 1]);
(new Tiger_Model_UserContact())->insert(['user_id' => $userId, 'contact_id' => $contactId, 'is_primary' => 1]);

return ['user_id' => $userId, 'email' => strtolower(trim((string) $v['email']))];
// The verification challenge is part of the account's unit of work — issue it INSIDE
// the transaction so it's atomic with the user. A challenge-write failure then rolls the
// whole signup back to a clean result=0, instead of committing an account and only THEN
// throwing (which reported failure on a live account the user could neither use nor
// re-create, blocked by email-uniqueness).
$token = bin2hex(random_bytes(32));
$challengeId = (new Tiger_Model_AuthChallenge())->issue($userId, 'email_verify', $token, self::VERIFY_TTL);

return [
'email' => strtolower(trim((string) $v['email'])),
'challenge_id' => $challengeId,
'token' => $token,
];
});

$this->_sendVerification($ids['user_id'], $ids['email']);
// Mail is best-effort I/O AFTER commit: the account already exists, so a mail hiccup must
// never surface as failure (_sendVerification swallows its own send errors).
$this->_sendVerification($ids['challenge_id'], $ids['token'], $ids['email']);
$this->_success(['sent' => 1, 'email' => $ids['email']], 'signup.check_email');
} catch (Throwable $e) {
$this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general');
Expand Down Expand Up @@ -119,12 +133,9 @@ protected function _uniqueSlug($base): string
return $slug;
}

/** Issue an email_verify challenge and email the verification link. */
protected function _sendVerification($userId, $email): void
/** Email the verification link for an already-issued email_verify challenge (best-effort I/O). */
protected function _sendVerification($challengeId, $token, $email): void
{
$token = bin2hex(random_bytes(32));
$challengeId = (new Tiger_Model_AuthChallenge())->issue($userId, 'email_verify', $token, self::VERIFY_TTL);

$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$url = $scheme . '://' . $host . '/signup/index/verify/cid/' . rawurlencode($challengeId) . '/code/' . rawurlencode($token);
Expand Down
54 changes: 50 additions & 4 deletions tests/COVERAGE-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,11 +512,57 @@ configured, not a hole). Limit moved onto the Select; regression test pins it.
6. v7 UUIDs collide within a millisecond (first 12 hex = ms) — the `substr(v7,0,12)` id idiom in tests is
latently flaky; use `bin2hex(random_bytes())` for unique fixture values.

### Wave 3 — the `/api` service + auth-service spine (LANDED 2026-07-24)
**Result:** 4 agents → **135 new tests** collected + verified together on one DB → the combined integration
suite is **250 tests / 845 assertions green** (was 116). Files: `Signup/SignupServiceTest` (19),
`Code/{RuntimeTest,CodeServiceTest}` + `System/{ModulesServiceTest,UpdatesServiceTest}` + `License/CheckerTest`
(35), `Access/{UserServiceTest,OrgServiceTest}` + `Cms/{PageServiceTest,MenuServiceTest,SettingsServiceTest}`
(55), `Service/AuthenticationTest` (26). Auth used Zend's `Zend_Session::$_unitTestEnabled` array-backed mode
to exercise the REAL session/lock/2FA/return-to paths under CLI (not stubs).

**Bug fixed at collection (a test surfaced it):** `Signup_Service_Signup::create()` committed the tenant graph
in `_transaction()` and only THEN issued the `email_verify` challenge + sent mail — with the challenge
`issue()` *outside* the mail try/catch. A throw there unwound into `create()`'s catch → `result=0` **on a
fully-committed account** (unusable, and un-recreatable behind email-uniqueness). Fix: issue the challenge
INSIDE the transaction (atomic with the user — a challenge-write failure now rolls the whole signup back);
`_sendVerification` reduced to best-effort post-commit mail. (Same class of "notification failure fails a
committed write" worth auditing elsewhere.)

**Findings (tracked, not fixed — characterized green):**
7. **Harness: base per-test `beginTransaction()` can't nest a service's own `_transaction()`** (ZF1's PDO
adapter doesn't ref-count; MySQL throws "already an active transaction"). Hit independently by 3 agents;
they worked around it (commit-and-purge / escape-and-scrub / drive the layer beneath). **Follow-up: add a
savepoint-aware/reentrant isolation mode to `IntegrationTestCase`** so service happy-paths test with clean
rollback isolation — unblocks every future service wave.
8. **`Cms_Service_Settings::save`** writes two `config` rows without a `_transaction()` — partial state
possible if the 2nd throws; diverges from the documented validate→transaction flow. (Same shape, benign
single-statement, in `Access_Service_Org::save` / `Cms_Service_Menu::save`.)
9. **`Tiger_Code_Runtime` writes real files** to `storage/cache/code` + `public/_code`; confirm both are
gitignored so a test/compile run can't leave artifacts staged.
10. DataTables `status`/`type` toolbar filters scope **both** `recordsTotal` and `recordsFiltered` (recordsTotal
is the filtered working set, not the grand total) — intentional per the model docblocks; noted for consumers.

### Wave 3 — the `/api` service + auth-service spine (agents' brief, 2026-07-24)
**Base scaffolding landed** on `test/int-base`: `IntegrationTestCase` now ships `login()`/`loginAs()`/
`logout()` (a real non-persistent `Zend_Auth` identity + the REAL shipped `Tiger_Acl_Acl` policy, so a
service's `_isAdmin()`/ACL gate decides against the rules that actually ship, not a fixture) — proven by
`ServiceScaffoldTest` (5 tests) dispatching the real admin-gated `Access_Service_User`. And `tests/bootstrap.php`
gained a **module-class autoloader** (`Mod_Type_Name` → `modules/<mod>/<types>/Name.php`, `Mod_XController` →
`controllers/`) registered LAST — so a real `/api` service + its form/model instantiate with no `require_once`
and no ZF1 module-resource-loader boot. This is the gate that unblocks the service wave.

Then **4 parallel agents** (own worktree + own DB `tiger_test_w3a-d`, off `test/int-base`):
- **A / signup** — `Signup_Service_Signup` (guest mass-create: happy path → user+org+membership, validation +
rollback, guest-allowed ACL).
- **B / RCE cluster** — `Tiger_Code_Runtime` (compile-gate/platform-scope), `Code_Service_Code`,
`System_Service_Modules`, `System_Service_Updates` (superadmin deny-by-default + nag-never-disable).
- **C / admin CRUD** — `Access_Service_User`/`Org`, `Cms_Service_Page`/`Menu`/`Settings` (ACL gate, datatable
envelope, validate→transaction, soft-delete/restore).
- **D / auth engine** — `Tiger_Service_Authentication` (password login, lockout, pepper, one-time challenges,
2FA orchestration). Collect → one DB → fold into ONE PR (dodges stacked-squash pain, per Waves 1+2).

### Next waves (unwritten — priority order per §5/§8)
- **Wave 3 — the `/api` service + auth-service spine:** needs a base enhancement first — add `login()` +
`installAcl()` helpers to `IntegrationTestCase` (identity + ACL scaffolding; the tiger-core base lacks
them). Then `Service_Authentication` (login/2FA/reset), `Signup_Service_Signup` (guest mass-create),
`System_Service_Modules` (untrusted install), `Code_Service_Code` (RCE lint gate). + `Tiger_Controller_Plugin_Authorization`.
- **Wave 3 tail:** `Tiger_Controller_Plugin_Authorization` (the unbypassable front-controller ACL gate).
- **Wave 4 — satellite repos:** stand up a harness in each, then TigerShield WAF engines (`Waf`/`Blocklist`/
`RateLimit`/`Challenge` — highest-value non-core), TigerDocs, then the commerce module repos.

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

namespace Tiger\Tests\Integration\Access;

use Access_Service_Org;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Tiger\Tests\Support\IntegrationTestCase;
use Tiger_Model_Org;
use Zend_Registry;

/**
* Access_Service_Org — the /api CRUD service behind the Organizations admin (datatable / save /
* delete).
*
* An org is a tenant with a self-referential parent and a URL-safe unique slug. Coverage: the ACL
* gate (admin+, deny-by-default), the DataTables envelope with server-computed per-row flags, the
* validate→write save (name required; slug slugified + uniqueness-guarded; created_by stamped; the
* new org gets its OWN generated org_id, not the actor's tenant; the parent-is-self guard), and the
* soft-delete with its "not the org you're acting in" guard.
*
* Note: Access_Service_Org::save() writes directly (no service-level _transaction), so these tests
* live entirely inside the harness's per-test transaction — no commit/scrub dance needed.
*/
#[CoversClass(Access_Service_Org::class)]
final class OrgServiceTest extends IntegrationTestCase
{
protected function setUp(): void
{
parent::setUp();
Zend_Registry::set('tiger.auth.stateless', true); // CSRF-immune API path (no session in CLI)
}

protected function tearDown(): void
{
$reg = Zend_Registry::getInstance();
if ($reg->offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); }
parent::tearDown();
}

private function call(string $action, array $params = []): object
{
return (new Access_Service_Org(['action' => $action] + $params))->getResponse();
}

// ----- ACL gate -----------------------------------------------------------------------------

#[Test]
public function guest_and_plain_user_are_denied_admin_clears(): void
{
$this->login('anon', 'org-test', 'guest');
$this->assertStringContainsString('not_allowed', json_encode($this->call('datatable')->messages), 'guest denied');

$this->loginAs('user');
$this->assertSame(0, (int) $this->call('datatable')->result, 'plain user denied');

$this->loginAs('admin');
$this->assertSame(1, (int) $this->call('datatable', ['draw' => 1])->result, 'admin allowed');
}

// ----- datatable ----------------------------------------------------------------------------

#[Test]
public function datatable_returns_the_envelope_and_flags(): void
{
$this->loginAs('admin');
(new Tiger_Model_Org())->insert(['name' => 'Acme Grid Co', 'slug' => 'acme-grid', 'status' => 'active']);

$res = $this->call('datatable', ['draw' => 3, 'start' => 0, 'length' => 25, 'search' => 'acme-grid']);
$data = $res->data;

$this->assertSame(3, $data['draw']);
$this->assertSame(1, $data['recordsFiltered'], 'search narrows to the one match');
$row = $data['data'][0];
$this->assertSame('Acme Grid Co', $row['name']);
$this->assertSame('acme-grid', $row['slug']);
$this->assertTrue($row['can_edit']);
$this->assertArrayHasKey('member_count', $row);
}

#[Test]
public function datatable_marks_the_current_org_and_blocks_deleting_it(): void
{
$orgId = (new Tiger_Model_Org())->insert(['name' => 'Current Tenant', 'slug' => 'current-tenant', 'status' => 'active']);
$this->login('actor', $orgId, 'admin'); // act IN this org

$res = $this->call('datatable', ['draw' => 1, 'start' => 0, 'length' => 100, 'search' => 'current-tenant']);
$mine = null;
foreach ($res->data['data'] as $r) { if ($r['org_id'] === $orgId) { $mine = $r; break; } }

$this->assertNotNull($mine);
$this->assertFalse($mine['can_delete'], 'you cannot delete the org you are acting in');
}

// ----- save ---------------------------------------------------------------------------------

#[Test]
public function save_persists_a_new_org_with_a_fresh_id_and_stamps_created_by(): void
{
$this->login('org-admin', 'org-test', 'admin');
$res = $this->call('save', ['name' => 'New Studio', 'slug' => 'New Studio!', 'status' => 'active']);

$this->assertSame(1, (int) $res->result);
$id = $res->data['org_id'];
$this->assertNotEmpty($id);
$this->assertNotSame('org-test', $id, 'the new org gets its OWN id, not the actor tenant');

$row = (new Tiger_Model_Org())->findById($id);
$this->assertSame('New Studio', $row->name);
$this->assertSame('new-studio', $row->slug, 'slug is slugified (lowercased, non-alnum -> hyphen)');
$this->assertSame('org-admin', $row->created_by, 'created_by stamped from the acting admin');
$this->assertSame(0, (int) $row->deleted);
}

#[Test]
public function save_derives_the_slug_from_the_name_when_slug_is_blank(): void
{
$this->loginAs('admin');
$res = $this->call('save', ['name' => 'Derived Name Org', 'slug' => '', 'status' => 'active']);
$this->assertSame(1, (int) $res->result);
$row = (new Tiger_Model_Org())->findById($res->data['org_id']);
$this->assertSame('derived-name-org', $row->slug);
}

#[Test]
public function an_invalid_payload_returns_form_errors_and_writes_no_row(): void
{
$this->loginAs('admin');
$before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM org');

$res = $this->call('save', ['name' => '', 'slug' => 'x', 'status' => 'active']);

$this->assertSame(0, (int) $res->result, 'name is required');
$this->assertNotNull($res->form);
$this->assertArrayHasKey('name', $res->form);
$this->assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM org'), 'no row written');
}

#[Test]
public function save_rejects_a_duplicate_slug(): void
{
$this->loginAs('admin');
(new Tiger_Model_Org())->insert(['name' => 'First', 'slug' => 'dup-slug', 'status' => 'active']);
$before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM org');

$res = $this->call('save', ['name' => 'Second', 'slug' => 'dup-slug', 'status' => 'active']);

$this->assertSame(0, (int) $res->result);
$this->assertStringContainsString('slug_taken', json_encode($res->messages));
$this->assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM org'));
}

#[Test]
public function save_refuses_an_org_that_is_its_own_parent(): void
{
$this->loginAs('admin');
$id = (new Tiger_Model_Org())->insert(['name' => 'Selfy', 'slug' => 'selfy', 'status' => 'active']);

$res = $this->call('save', ['org_id' => $id, 'name' => 'Selfy', 'slug' => 'selfy', 'parent_org_id' => $id, 'status' => 'active']);
$this->assertSame(0, (int) $res->result, 'an org cannot parent itself');
$this->assertStringContainsString('parent_self', json_encode($res->messages));
}

#[Test]
public function save_updates_an_existing_org_in_place(): void
{
$this->loginAs('admin');
$id = (new Tiger_Model_Org())->insert(['name' => 'Old Name', 'slug' => 'old-slug', 'status' => 'active']);

$res = $this->call('save', ['org_id' => $id, 'name' => 'New Name', 'slug' => 'old-slug', 'status' => 'suspended']);
$this->assertSame(1, (int) $res->result);
$this->assertSame($id, $res->data['org_id']);

$row = (new Tiger_Model_Org())->findById($id);
$this->assertSame('New Name', $row->name);
$this->assertSame('suspended', $row->status);
}

// ----- delete (soft-delete) -----------------------------------------------------------------

#[Test]
public function delete_soft_deletes_and_reads_exclude_it(): void
{
$this->loginAs('admin');
$model = new Tiger_Model_Org();
$id = $model->insert(['name' => 'Doomed Org', 'slug' => 'doomed-org', 'status' => 'active']);

$this->assertSame(1, (int) $this->call('delete', ['org_id' => $id])->result);
$this->assertSame(1, (int) $this->db->fetchOne('SELECT deleted FROM org WHERE org_id = ?', [$id]));
$this->assertNull($model->findById($id), 'a deleted org is excluded from reads');
}

#[Test]
public function delete_refuses_the_org_you_are_acting_in(): void
{
$model = new Tiger_Model_Org();
$id = $model->insert(['name' => 'My Tenant', 'slug' => 'my-tenant', 'status' => 'active']);
$this->login('actor', $id, 'admin');

$res = $this->call('delete', ['org_id' => $id]);
$this->assertSame(0, (int) $res->result);
$this->assertStringContainsString('no_self_delete', json_encode($res->messages));
$this->assertSame(0, (int) $this->db->fetchOne('SELECT deleted FROM org WHERE org_id = ?', [$id]), 'still live');
}
}
Loading
Loading