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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ jobs:
TIGER_TEST_DB_PASS: root
# The floor we enforce today (report-only warning above it). Bump this as coverage climbs so it
# can only go UP — the ratchet that gets us to 90% without ever regressing.
MIN_COVERAGE: '35'
MIN_COVERAGE: '55'
steps:
- uses: actions/checkout@v4

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
/.phpunit.cache/
/storage/
/public/_code/
/var/

# 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.
Expand Down
2 changes: 1 addition & 1 deletion library/Tiger/Model/AgentMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class Tiger_Model_AgentMessage extends Tiger_Model_Table
* @param string $runId the turn that produced it ('' for a bare user message)
* @return string the new message_id
*/
public function append($conversationId, $role, $content, array $meta = null, $runId = '')
public function append($conversationId, $role, $content, ?array $meta = null, $runId = '')
{
return $this->insert([
'conversation_id' => (string) $conversationId,
Expand Down
29 changes: 22 additions & 7 deletions tests/COVERAGE-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -594,14 +594,29 @@ cluster ~2–8×'d (`Dependency` 97 · `License_Authority` 94 · `Vendor` 80 ·
full MVC/view boot the harness skips. These are functional/HTTP-test concerns — the % gaps below ~90 on
media/profile/backup are mostly these, not missing logic.

### Wave 5 — the library/Tiger KERNEL (LANDED 2026-07-24) → coverage 37.9% → 59.2%
7 parallel agents by sub-package, **+~584 tests (861 → 1445 combined green)**, kernel **library/Tiger 32% → 66%**.
Per-subpackage lines: Model 51→97 · Service 57→89 · Ajax 70→89 · Controller 16→77 · Admin 0→81 · Session 0→96 ·
View 0→98 · OpenApi 0→94 · Generator 0→100 · Policy 85→100 · Acl 85→89 · Location 7→84 · I18n→95 · Validate 21→74 ·
Media(lib) 25→~85 · Backup 0→~90 · Code(lib) 44→~85 · Agent(lib) 3→~72 · Google 0→29 · Application 0→60. **No behavioral
bugs.** One forward-compat source fix: `Tiger_Model_AgentMessage::append()` implicit-nullable `array $meta=null` → `?array`
(PHP 8.5 deprecation, error in PHP 9). **Remaining kernel gap (~3,150 lines) is the genuinely-hard I/O:** the live-boot
`Tiger_Application_Bootstrap::_init*` orchestration, `Update_Composer`/`Update_Core` (composer proc_open + `vendor/` swap),
provider/authority/GA/reCAPTCHA live HTTP, ClamAV/Rekognition scanners, AWS-SDK S3 I/O — all functional/live territory.
- **Cross-test isolation footguns** the agents surfaced (each guarded locally; recorded for future waves): `Tiger_Model_Org::$_siteOrgId`
process-static must be reset to EXACTLY `null` (restoring `''` poisons lazy site-org resolution); `Zend_Translate`
set to null in the registry stays registered-as-null → use `offsetUnset`, not `set(null)`; `Tiger_Application::defineConstants()`
mints `MODULES_PATH` etc. → the app-boot test runs `#[RunInSeparateProcess]`. **RECOMMENDED base hygiene (deferred):** reset
`Org::$_siteOrgId` in `IntegrationTestCase::tearDown`. CI floor `MIN_COVERAGE` 35 → 55.

### Next waves (priority order) — the drive to 90%
- **Wave 5 — the library/Tiger KERNEL** (9,176 lines @ 32% = the dominant remaining gap, ~6,200 uncovered):
the CMS engine (`Cms_*`, `Menu`, `Page*` finders), `Mail`, `Log`, `Location` adapters, `Admin_*` registries,
`Form`/`Validate`/`Form_Element_*`, `Ajax_ServiceFactory` remainder, and the untested branches of the Model
+ Service classes. This is what actually moves the overall number to the 80s. Parallel agents by sub-package.
- **Wave 6 — the remaining modules:** `agent` (462 @ 5% — needs a provider-adapter stub), `cms` module (510 @
33%), `code` (238 @ 41%), and `core/controllers` (360 @ 0% — needs the controller-dispatch harness).
- **Wave 7 — satellite repos:** stand up a harness in each, then TigerShield WAF engines first.
- **Wave 6 — the remaining MODULES:** `agent` module (462 @ 5% — needs a provider-adapter stub, but the `Tiger_Agent_*`
library it builds on is now ~72% so the seams exist), `cms` module (510 @ 33%), `system` remainder (579 @ 37%), `code`
module (238 @ 41%), profile/media/analytics/backup/identity remainders (mostly upload-ceiling + render-only), and
`core/controllers` (360 @ 0% — needs a controller-dispatch harness). ~2,700 reachable module lines.
- **Wave 7 — the hard kernel remainder:** an integration harness that boots enough of `Tiger_Application` to cover the
`_init*` cascade; live-I/O seams for Update/Github behind a real local fixture server if worth it.
- **Wave 8 — satellite repos:** stand up a harness in each, then TigerShield WAF engines first.

---
*Manifest generated 2026-07-18 by 6 parallel read-only scans; graduated into `tests/COVERAGE-PLAN.md` +
Expand Down
235 changes: 235 additions & 0 deletions tests/Integration/Agent/ForgeAclTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.

namespace Tiger\Tests\Integration\Agent;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Tiger\Tests\Support\IntegrationTestCase;
use Tiger_Agent_Forge;
use Tiger_Log;
use Zend_Config;
use Zend_Registry;

// The module-scaffold "already exists" guard checks the app-modules root. In isolation MODULES_PATH
// isn't defined; pin it to the SAME value Ally's integration test uses so the check is deterministic.
if (!defined('MODULES_PATH')) {
define('MODULES_PATH', TIGER_CORE_PATH . '/modules');
}

/** A Forge whose write sandbox is redirected to a temp file, so the approved-write happy path can
* run without touching the real application/modules tree. Everything else is the real Forge. */
final class TempForge extends Tiger_Agent_Forge
{
public string $target = '';
protected function _resolveModulePath($path): ?string { return $this->target !== '' ? $this->target : null; }
}

/**
* Tiger_Agent_Forge — the permission-gated "hands" of the agent, exercised against the REAL shipped
* ACL (core + every module's acl.ini, via the integration login). It verifies the twin gate: the ACL
* (deny-by-default, the same rule a human hits) and the human-in-the-loop rule (writes wait for an
* explicit approval). The tiers fall straight out of the agent acl.ini — code/file at superadmin,
* module at developer — so a role that lacks the privilege is refused, and an allowed-but-unapproved
* write is deferred, never run. The live /api dispatch of an approved read/write is the documented
* boundary (it would run a real downstream service), so it isn't driven here.
*/
#[CoversClass(Tiger_Agent_Forge::class)]
final class ForgeAclTest extends IntegrationTestCase
{
#[Test]
public function anUnknownActionTypeIsACleanError(): void
{
$this->loginAs('developer');
$entry = (new Tiger_Agent_Forge('developer'))->execute(['type' => 'nope', 'reason' => 'x']);
$this->assertSame('error', $entry['status']);
$this->assertStringContainsString('Unknown action type', $entry['summary']);
}

// ----- api tier ---------------------------------------------------------

#[Test]
public function aGuestIsDeniedAnApiWrite(): void
{
$this->loginAs('guest');
$entry = (new Tiger_Agent_Forge('guest'))->execute([
'type' => 'api', 'module' => 'access', 'service' => 'user', 'method' => 'create', 'params' => [], 'reason' => 'r',
]);
$this->assertSame('denied', $entry['status']);
$this->assertArrayHasKey('action', $entry); // the proposal is preserved for the ledger
}

#[Test]
public function anAllowedButUnapprovedApiWriteIsDeferredNotDispatched(): void
{
$this->loginAs('admin'); // admin may call Cms_Service_Page (the real cms acl.ini)
$entry = (new Tiger_Agent_Forge('admin'))->execute([
'type' => 'api', 'module' => 'cms', 'service' => 'page', 'method' => 'save',
'params' => ['title' => 'FAQ'], 'reason' => 'create the page',
]);
$this->assertSame('proposed', $entry['status']);
$this->assertStringContainsString('cms/page/save', $entry['summary']);
}

#[Test]
public function anApprovedApiWriteDispatchesTheRealServiceAndReportsItsResult(): void
{
$this->loginAs('admin');
// Approved → the Forge dispatches Cms_Service_Page for real. Empty params fail the page form,
// so nothing persists, but the dispatch + response-message flattening + ledger entry all run.
$entry = (new Tiger_Agent_Forge('admin'))->execute([
'type' => 'api', 'module' => 'cms', 'service' => 'page', 'method' => 'save',
'params' => [], 'reason' => 'create a page', 'approved' => true,
]);
$this->assertContains($entry['status'], ['done', 'error']); // it dispatched, whatever the verdict
$this->assertStringContainsString('cms/page/save', $entry['summary']);
$this->assertArrayHasKey('result', $entry['detail']);
}

// ----- code tier (superadmin+) ------------------------------------------

#[Test]
public function writingExecutableCodeIsDeniedBelowSuperadmin(): void
{
$this->loginAs('manager');
$entry = (new Tiger_Agent_Forge('manager'))->execute([
'type' => 'code', 'name' => 's', 'language' => 'php', 'code' => '<?php 1;', 'reason' => 'r',
]);
$this->assertSame('denied', $entry['status']);
}

#[Test]
public function anAllowedButUnapprovedCodeWriteIsDeferredNotRun(): void
{
$this->loginAs('superadmin');
$entry = (new Tiger_Agent_Forge('superadmin'))->execute([
'type' => 'code', 'name' => 'helper', 'language' => 'php', 'code' => '<?php 1;', 'reason' => 'r',
]);
$this->assertSame('proposed', $entry['status']);
$this->assertStringContainsString('helper', $entry['summary']);
}

// ----- file tier (superadmin+) ------------------------------------------

#[Test]
public function writingAModuleFileIsDeniedBelowSuperadmin(): void
{
$this->loginAs('admin'); // admin has inventory, but NOT the sharp file tier
$entry = (new Tiger_Agent_Forge('admin'))->execute([
'type' => 'file', 'path' => 'demo/x.phtml', 'contents' => '<h1>x</h1>', 'reason' => 'r',
]);
$this->assertSame('denied', $entry['status']);
}

#[Test]
public function anAllowedButUnapprovedFileWriteIsDeferred(): void
{
$this->loginAs('superadmin');
$entry = (new Tiger_Agent_Forge('superadmin'))->execute([
'type' => 'file', 'path' => 'demo/x.phtml', 'contents' => '<h1>x</h1>', 'reason' => 'r',
]);
$this->assertSame('proposed', $entry['status']);
$this->assertStringContainsString('demo/x.phtml', $entry['summary']);
}

#[Test]
public function anApprovedPhpFileThatWouldNotParseIsRefusedBeforeItHitsDisk(): void
{
$this->loginAs('superadmin');
$tmp = sys_get_temp_dir() . '/tiger-forge-' . uniqid() . '/broken.php';

$forge = new TempForge('superadmin');
$forge->target = $tmp;
$entry = $forge->execute([
'type' => 'file', 'path' => 'demo/broken.php', 'contents' => '<?php function (( {{ not php',
'reason' => 'r', 'approved' => true,
]);

$this->assertSame('error', $entry['status']);
$this->assertStringContainsString('would not parse', $entry['summary']);
$this->assertFileDoesNotExist($tmp); // the lint gate refuses it before any write
}

#[Test]
public function anApprovedFileWriteLandsOnDiskInsideTheSandbox(): void
{
$this->loginAs('superadmin');
$tmp = sys_get_temp_dir() . '/tiger-forge-' . uniqid() . '/nested/view.phtml';

$forge = new TempForge('superadmin');
$forge->target = $tmp;

// A successful write emits a Tiger_Log line via the ErrorLog writer (→ stderr), which the
// strict "no unexpected output" rule flags risky. Point the logger at the Null writer for the
// duration of the write, then restore the registry EXACTLY as it was.
$had = Zend_Registry::isRegistered('Zend_Config');
$prev = $had ? Zend_Registry::get('Zend_Config') : null;
Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['log' => ['writer' => 'null']]]));
Tiger_Log::reset();
try {
$entry = $forge->execute([
'type' => 'file', 'path' => 'demo/nested/view.phtml', 'contents' => '<h1>hello</h1>',
'reason' => 'r', 'approved' => true,
]);
} finally {
if ($had) { Zend_Registry::set('Zend_Config', $prev); }
else { Zend_Registry::getInstance()->offsetUnset('Zend_Config'); }
Tiger_Log::reset();
}

$this->assertSame('done', $entry['status']);
$this->assertFileExists($tmp);
$this->assertSame('<h1>hello</h1>', file_get_contents($tmp));

@unlink($tmp);
@rmdir(dirname($tmp));
@rmdir(dirname($tmp, 2));
}

// ----- module tier (developer only) -------------------------------------

#[Test]
public function scaffoldingAModuleIsDeniedBelowDeveloper(): void
{
$this->loginAs('superadmin'); // superadmin has file, but module is developer-only
$entry = (new Tiger_Agent_Forge('superadmin'))->execute([
'type' => 'module', 'name' => 'widgets', 'reason' => 'r',
]);
$this->assertSame('denied', $entry['status']);
}

#[Test]
public function anAllowedButUnapprovedModuleScaffoldIsDeferred(): void
{
$this->loginAs('developer');
$entry = (new Tiger_Agent_Forge('developer'))->execute([
'type' => 'module', 'name' => 'widgets', 'reason' => 'r',
]);
$this->assertSame('proposed', $entry['status']);
$this->assertStringContainsString('widgets', $entry['summary']);
}

#[Test]
public function anApprovedModuleScaffoldWithAnEmptyNameIsAnError(): void
{
$this->loginAs('developer');
$entry = (new Tiger_Agent_Forge('developer'))->execute([
'type' => 'module', 'name' => '', 'reason' => 'r', 'approved' => true,
]);
$this->assertSame('error', $entry['status']);
$this->assertStringContainsString('valid module name', $entry['summary']);
}

#[Test]
public function anApprovedModuleScaffoldRefusesAnExistingModuleName(): void
{
$this->loginAs('developer');
// 'agent' already exists under the modules root → the Forge refuses to clobber it.
$entry = (new Tiger_Agent_Forge('developer'))->execute([
'type' => 'module', 'name' => 'agent', 'reason' => 'r', 'approved' => true,
]);
$this->assertSame('error', $entry['status']);
$this->assertStringContainsString('already exists', $entry['summary']);
}
}
Loading
Loading