Testing your panel
Prove your driver returns the exact contract shapes — with a syntax check, a faked-HTTP harness, and a static-scanner pass — before you submit it to the marketplace.
On this page
- Why test in isolation
- Step 1: a syntax check with php -l
- Step 2: assert the type and the capabilities
- Step 3: a faked-HTTP contract harness
- create() returns the data keys Core stores
- Lifecycle methods — and the suspend wrapper
- accountSummary(): the usage math, and null-when-absent
- Error paths must fail closed
- Keep the static safety scanner happy
- Pre-submit checklist
A panel driver is code that Core will call in production against real customer control panels. You cannot hot-patch it on a live install — the marketplace signs the artifact on approval, and every installed file is hash-checked at load time (see Security & trust model). So the time to catch a wrong return shape, a missed null, or an unhandled error is before you submit. This article covers how to test a driver in isolation, against canned panel responses, so you know it honours the contract every way Core will read it.
Why test in isolation#
You cannot test a panel by dropping its folder into a running Core. The PanelRegistry refuses to load an extension that has no signed, entitled panel_extensions row, so a hand-copied folder loads nothing. Those three gates are a distribution concern, and they are deliberately not in the loop when you exercise the class yourself.
That is actually good news: your driver is a plain PHP class. In a test you construct it directly with new YourPanel() and call its methods. Everything the driver needs is passed in as arguments ($server, $hosting) or reached through the helper trait's HTTP client — nothing depends on the registry. So a focused test suite around the class covers exactly what production will invoke.
Because AbstractPanel supplies a safe "not supported" default for every method, you can also build the driver — and its tests — one method at a time. Implement create(), test it, declare PanelCapability::CREATE; then move to suspend(). Every method you haven't written yet still returns a well-formed failure, so a half-finished driver is testable at every step.
Step 1: a syntax check with php -l#
Before anything clever, make sure every file parses. Lint the driver and any helper classes in your package:
find your-panel -name '*.php' -print0 | xargs -0 -n1 php -lphp -l only checks that the file parses — it says nothing about behaviour or the static safety scan the reviewer runs later. Treat a clean lint as the entry ticket, not a pass.
Step 2: assert the type and the capabilities#
Two things Core relies on before it ever calls an API method: your driver is the right type, and it declares only capabilities it truly implements.
use App\HostingModule\Server\HostingManagerInterface;
use App\HostingModule\Server\PanelCapability;
use Salieno\Panel\Cpanel\Cpanel;
public function test_driver_is_a_hosting_manager(): void
{
$panel = new Cpanel();
$this->assertInstanceOf(HostingManagerInterface::class, $panel);
}
public function test_capabilities_are_recognised_keys(): void
{
$panel = new Cpanel();
foreach ($panel->capabilities() as $capability) {
$this->assertContains($capability, PanelCapability::ALL);
}
// The capabilities you intend to ship:
$this->assertContains(PanelCapability::CREATE, $panel->capabilities());
}Extending AbstractPanel makes the instanceof check pass for free. The capabilities check guards a subtler mistake: a typo like 'sync_info' instead of 'sync' is silently dropped by Core's sanitizer, so the control never appears. Asserting every declared key is in PanelCapability::ALL catches that at test time. See Capabilities for the full map of capability to UI control — and remember to pair each capability you declare with a happy-path test of the method behind it.
Step 3: a faked-HTTP contract harness#
The heart of your suite is a set of tests that feed the driver canned panel responses and assert that each method returns the exact shape Core reads. The trick that makes this clean: the helper trait's panelHttp() issues every request through Laravel's Http facade, so Http::fake() intercepts them all. You never touch a real server.
Run these inside a Core checkout, extending Core's base test case, so the Http facade, the __() translator, and the global formatBytesAsFriendly() helper are all bootstrapped. Pull your driver file in and build lightweight doubles for the models — anonymous classes with the properties the driver reads plus a no-op save(), so no database is required:
use Illuminate\Support\Facades\Http;
use Salieno\Panel\Cpanel\Cpanel;
use Tests\TestCase;
class CpanelContractTest extends TestCase
{
private function fakeServer(): object
{
return (object) [
'hostname' => 'https://panel.example.com:2087',
'username' => 'root',
'api_token' => 'TEST-TOKEN',
'password' => null,
'verify_ssl' => false,
'ip_address' => '192.0.2.10',
'id' => 1,
];
}
private function fakeHosting(): object
{
$hosting = new class {
public $domain = 'example.com';
public $username = 'exampleu';
public $password = 'S3cret-Pass-9x';
public $ip; public $ns1; public $ns2; public $ns3; public $ns4;
public $package_name;
public $user; public $product; public $server;
public function save(): void {}
};
$hosting->user = (object) ['email' => '[email protected]'];
$hosting->product = (object) ['package_name' => 'starter', 'product_type' => 1];
$hosting->server = $this->fakeServer();
return $hosting;
}
}The URL patterns and JSON below are cPanel/WHM's, matching the worked example in panels/cpanel. Swap them for your panel's endpoints and payloads — the assertions are what carry over.
create() returns the data keys Core stores#
create() must return success, message, and a data array whose password is the real value (Core stores it on the service and emails it to the customer). Omit a key to keep Core's pre-generated value; never set the service status yourself.
public function test_create_returns_provisioning_data(): void
{
Http::fake([
'*json-api/createacct*' => Http::response([
'metadata' => ['result' => 1, 'reason' => 'Account Creation Ok'],
'data' => [
'ip' => '192.0.2.10',
'nameserver' => 'ns1.example.com',
'nameserver2' => 'ns2.example.com',
],
], 200),
]);
$result = (new Cpanel())->create($this->fakeHosting());
$this->assertTrue($result['success']);
$this->assertArrayHasKey('data', $result);
$this->assertSame('exampleu', $result['data']['username']);
$this->assertSame('S3cret-Pass-9x', $result['data']['password']); // real one echoed back
$this->assertSame('192.0.2.10', $result['data']['ip']);
$this->assertSame('ns1.example.com', $result['data']['nameserver1']);
// Credentials must travel in the POST body, not the query string:
Http::assertSent(fn ($request) =>
str_contains($request->url(), '/json-api/createacct')
&& $request['username'] === 'exampleu'
);
}Http::assertSent() is worth a habit: it verifies the request you sent, not just the response you handled — a cheap way to prove a password never leaks into a query string.
Lifecycle methods — and the suspend wrapper#
unSuspend(), terminate(), changePackage(), and changePassword() all take the bare $hosting and return ['success' => bool, 'message' => string]. suspend() is the one exception in the whole contract: its argument is an array wrapper, ['hosting' => Hosting, 'request' => object]. Getting that wrong is the single most common integration bug, so pin it down with a test:
public function test_suspend_takes_the_array_wrapper(): void
{
Http::fake([
'*json-api/suspendacct*' => Http::response(['metadata' => ['result' => 1]], 200),
]);
$request = (object) ['suspend_reason' => 'Non-payment', 'suspend_email' => false];
$result = (new Cpanel())->suspend([
'hosting' => $this->fakeHosting(),
'request' => $request,
]);
$this->assertTrue($result['success']);
$this->assertIsString($result['message']);
}
public function test_terminate_takes_the_bare_model(): void
{
Http::fake([
'*json-api/removeacct*' => Http::response(['metadata' => ['result' => 1]], 200),
]);
$result = (new Cpanel())->terminate($this->fakeHosting());
$this->assertTrue($result['success']);
}Note the exact camelCase: the method is unSuspend, not unsuspend. A test that calls it is the fastest way to notice a casing slip.
accountSummary(): the usage math, and null-when-absent#
accountSummary() drives the client usage meters. Its processed_data must carry disk_usage_percent and bandwidth_usage_percent as numbers, with the sizes as pre-formatted strings. Assert the arithmetic on known inputs:
public function test_account_summary_computes_usage_percentages(): void
{
Http::fake([
'*json-api/accountsummary*' => Http::response([
'metadata' => ['result' => 1],
'data' => ['acct' => [[
'user' => 'exampleu',
'diskused' => '512', 'disklimit' => '1024', // MB
'bwused' => '2048', 'bwlimit' => '10240', // MB
'ip' => '192.0.2.10',
'plan' => 'starter',
'domain' => 'example.com',
]]],
], 200),
]);
$summary = (new Cpanel())->accountSummary($this->fakeHosting());
$this->assertNotNull($summary);
$this->assertEqualsWithDelta(50.0, $summary['processed_data']['disk_usage_percent'], 0.01);
$this->assertEqualsWithDelta(20.0, $summary['processed_data']['bandwidth_usage_percent'], 0.01);
$this->assertIsString($summary['processed_data']['disk_used']); // formatted, not raw
}Then the rule Core depends on most: accountSummary() must be falsey when the account does not exist, because Core uses its truthiness to detect whether an account is present at all. Test that your driver turns its panel's "no such account" response into null — never a populated array of zeros:
public function test_account_summary_is_null_when_the_account_is_absent(): void
{
$hosting = $this->fakeHosting();
$hosting->username = null; // force the driver's lookup path
$hosting->domain = 'ghost.example.com';
Http::fake([
'*json-api/listaccts*' => Http::response([
'metadata' => ['result' => 1],
'data' => ['acct' => []], // no account matches
], 200),
]);
$this->assertNull((new Cpanel())->accountSummary($hosting));
}If your panel's API returns a distinct "not found" code or an empty record, map that to null in the driver and assert it here. This is the behaviour that keeps the client area from rendering a fake "0 of 0 used" meter for an account that was never provisioned.
Error paths must fail closed#
Every method must return a clean success => false when the panel says no or the connection breaks — never throw, never return a truthy result. Test both an API-level error envelope and an unreachable/garbage response:
public function test_create_fails_closed_on_api_error(): void
{
Http::fake([
'*json-api/createacct*' => Http::response([
'metadata' => ['result' => 0, 'reason' => 'The username already exists.'],
], 200),
]);
$result = (new Cpanel())->create($this->fakeHosting());
$this->assertFalse($result['success']);
$this->assertNotEmpty($result['message']);
$this->assertArrayNotHasKey('data', $result); // a failed create carries no provisioning data
}
public function test_create_fails_closed_on_a_non_json_login_page(): void
{
// A WAF or a redirect to an HTML login page is a common real failure.
Http::fake([
'*' => Http::response('<html><body>Login</body></html>', 200),
]);
$result = (new Cpanel())->create($this->fakeHosting());
$this->assertFalse($result['success']);
$this->assertIsString($result['message']);
}The second test exercises the trait's tolerant JSON handling: a non-JSON body decodes to nothing, the driver surfaces a clear operator message instead of a fatal error. If your driver returns raw connection text, wrap it in sanitizeErrorMessage() first — that both masks IPs, paths, and tokens and rewrites low-level cURL failures (SSL, timeout, DNS, refused) into actionable guidance. You can prove the translation by faking a timeout and asserting the message mentions it rather than leaking cURL error 28.
Keep the static safety scanner happy#
When you submit, the marketplace runs a static safety scan over your files before a human reviews them. It flags dynamic code-execution patterns — the shell and eval family (eval, exec, shell_exec, system, passthru, proc_open, backtick execution, create_function, variable-variable calls, and dynamic include/require). A panel driver never needs any of them: it makes HTTP calls through panelHttp() and nothing else. If the scanner finds one, expect a rejection.
The gotcha is false positives. The scan matches on token patterns, not on whether the code path is reachable — so a function name from that list immediately followed by an open paren can trip it even inside a comment or a string literal. A stray line like // we never call system() here to run anything reads as safe to a human and as a violation to the scanner. Keep those tokens out of comments and strings entirely; if you must mention one, describe it in words ("the shell-execution builtins") rather than writing the name-and-paren. Remember that php -l will happily parse code the scanner rejects — the two checks are unrelated, so pass both. More on what the reviewer verifies is in Publishing your panel.
Pre-submit checklist#
Before you package and submit:
php -lis clean on every.phpfile in the package.- The driver is
instanceof HostingManagerInterface, andcapabilities()returns only keys inPanelCapability::ALL. - Every declared capability has a happy-path test proving its method returns the right shape.
create()echoes the realdata.passwordand does not set the service status.suspend()is tested through the['hosting' => ..., 'request' => ...]wrapper;unSuspendcasing is correct.accountSummary()returns numeric usage percentages when present andnullwhen the account is absent.- Every method fails closed —
success => false, no throw — on an API error, a non-JSON body, and a connection failure. - No shell/eval token appears anywhere in the package, comments and strings included.
With those green, your driver behaves the same in every surface Core renders from it. The remaining steps — signing, entitlement, install — are the marketplace's job, covered in Publishing your panel.