'whm.example.com', 'username' => 'admin', 'password' => 'secret'], $httpClient) extends Server_Manager_Whm { public function __construct(array $options, private readonly HttpClientInterface $httpClient) { parent::__construct($options); } public function getHttpClient(): HttpClientInterface { return $this->httpClient; } }; } function createWhmAccount(Server_Package $package, bool $reseller): Server_Account { return (new Server_Account()) ->setUsername('example') ->setDomain('example.com') ->setPassword('secret') ->setClient((new Server_Client())->setEmail('client@example.com')) ->setPackage($package) ->setReseller($reseller); } function actionOf(array $request): string { return basename((string) parse_url($request['url'], PHP_URL_PATH)); } function createWhmAccountCreationClient(array &$requests): MockHttpClient { return new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { $requests[] = ['method' => $method, 'url' => $url, 'options' => $options]; if (str_contains($url, 'listpkgs')) { return new MockResponse(json_encode(['package' => []])); } if (str_contains($url, 'listaccts')) { return new MockResponse(json_encode(['metadata' => ['result' => 1], 'data' => ['acct' => [['user' => 'example', 'domain' => 'example.com', 'owner' => 'admin', 'plan' => 'admin_FOSSBilling']]]])); } if (str_contains($url, 'createacct')) { return new MockResponse(json_encode(['result' => [['status' => 1]]])); } return new MockResponse(json_encode(['status' => 1])); }); } test('createAccount sets up the reseller but does not touch the ACL list when the plan has no acl custom value', function (): void { $requests = []; $manager = createWhmManager(createWhmAccountCreationClient($requests)); $account = createWhmAccount(new Server_Package(), true); expect($manager->createAccount($account))->toBeTrue(); $actions = array_map(actionOf(...), $requests); // Calling "setacls" with an empty acllist would strip the initial privileges "setupreseller" // just granted, so it must be skipped when the plan has no 'acl' custom value. expect($actions)->toBe(['listpkgs', 'addpkg', 'createacct', 'listaccts', 'setupreseller']) ->and($actions)->not->toContain('setacls'); }); test('createAccount assigns the plan\'s acl custom value to the reseller via setacls', function (): void { $requests = []; $manager = createWhmManager(createWhmAccountCreationClient($requests)); $package = (new Server_Package())->setCustomValues(['acl' => 'reseller_basic']); $account = createWhmAccount($package, true); expect($manager->createAccount($account))->toBeTrue(); $actions = array_map(actionOf(...), $requests); expect($actions)->toBe(['listpkgs', 'addpkg', 'createacct', 'listaccts', 'setupreseller', 'setacls']); $setaclsRequest = $requests[array_search('setacls', $actions, true)]; parse_str((string) $setaclsRequest['options']['body'], $fields); expect($fields)->toMatchArray([ 'reseller' => 'example', 'acllist' => 'reseller_basic', ]); }); test('synchronizeAccount casts the suspended flag to bool', function (): void { $client = new MockHttpClient(fn (): MockResponse => new MockResponse(json_encode([ 'acct' => [ ['suspended' => 1, 'domain' => 'example.com', 'user' => 'example', 'ip' => '1.2.3.4'], ], ]))); $manager = createWhmManager($client); $account = createWhmAccount(new Server_Package(), false); $updated = $manager->synchronizeAccount($account); expect($updated->getSuspended())->toBeTrue(); }); test('createAccount never calls setupreseller or setacls for a non-reseller account', function (): void { $requests = []; $manager = createWhmManager(createWhmAccountCreationClient($requests)); $package = (new Server_Package())->setCustomValues(['acl' => 'reseller_basic']); $account = createWhmAccount($package, false); expect($manager->createAccount($account))->toBeTrue(); $actions = array_map(actionOf(...), $requests); expect($actions)->toBe(['listpkgs', 'addpkg', 'createacct']); }); test('createAccount skips addpkg when the package is already listed (legacy format)', function (): void { $requests = []; $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { $requests[] = ['method' => $method, 'url' => $url, 'options' => $options]; if (str_contains($url, 'listpkgs')) { return new MockResponse(json_encode(['package' => [['name' => 'admin_FOSSBilling']]])); } if (str_contains($url, 'createacct')) { return new MockResponse(json_encode(['result' => [['status' => 1]]])); } return new MockResponse(json_encode(['status' => 1])); }); $manager = createWhmManager($client); $account = createWhmAccount(new Server_Package(), false); expect($manager->createAccount($account))->toBeTrue(); $actions = array_map(actionOf(...), $requests); expect($actions)->toBe(['listpkgs', 'createacct']); }); test('createAccount skips addpkg when listpkgs uses the modern data.pkg format', function (): void { $requests = []; $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { $requests[] = ['method' => $method, 'url' => $url, 'options' => $options]; if (str_contains($url, 'listpkgs')) { return new MockResponse(json_encode(['data' => ['pkg' => [['name' => 'admin_FOSSBilling']]]])); } if (str_contains($url, 'createacct')) { return new MockResponse(json_encode(['result' => [['status' => 1]]])); } return new MockResponse(json_encode(['status' => 1])); }); $manager = createWhmManager($client); $account = createWhmAccount(new Server_Package(), false); expect($manager->createAccount($account))->toBeTrue(); $actions = array_map(actionOf(...), $requests); expect($actions)->toBe(['listpkgs', 'createacct']); }); test('createAccount updates the existing package via editpkg when addpkg reports already exists (see #4560)', function (): void { $requests = []; $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { $requests[] = ['method' => $method, 'url' => $url, 'options' => $options]; if (str_contains($url, 'listpkgs')) { return new MockResponse(json_encode(['package' => []])); } if (str_contains($url, 'addpkg')) { return new MockResponse(json_encode(['result' => [['status' => 0, 'statusmsg' => '(XID n25fcu) The package "admin_FOSSBilling" already exists. If you wish to make changes, please edit the package.']]])); } if (str_contains($url, 'editpkg')) { return new MockResponse(json_encode(['status' => 1])); } if (str_contains($url, 'createacct')) { return new MockResponse(json_encode(['result' => [['status' => 1]]])); } return new MockResponse(json_encode(['status' => 1])); }); $manager = createWhmManager($client); $account = createWhmAccount(new Server_Package(), false); expect($manager->createAccount($account))->toBeTrue(); $actions = array_map(actionOf(...), $requests); expect($actions)->toBe(['listpkgs', 'addpkg', 'editpkg', 'createacct']); }); test('createAccount still creates the account when editpkg also fails after already-exists (see #4560)', function (): void { $requests = []; $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { $requests[] = ['method' => $method, 'url' => $url, 'options' => $options]; if (str_contains($url, 'listpkgs')) { return new MockResponse(json_encode(['package' => []])); } if (str_contains($url, 'addpkg')) { return new MockResponse(json_encode(['result' => [['status' => 0, 'statusmsg' => '(XID n25fcu) The package "admin_FOSSBilling" already exists.']]])); } if (str_contains($url, 'editpkg')) { return new MockResponse(json_encode(['result' => [['status' => 0, 'statusmsg' => 'Permission denied']]])); } if (str_contains($url, 'createacct')) { return new MockResponse(json_encode(['result' => [['status' => 1]]])); } return new MockResponse(json_encode(['status' => 1])); }); $manager = createWhmManager($client); $account = createWhmAccount(new Server_Package(), false); expect($manager->createAccount($account))->toBeTrue(); $actions = array_map(actionOf(...), $requests); expect($actions)->toBe(['listpkgs', 'addpkg', 'editpkg', 'createacct']); }); test('createAccount rethrows addpkg failures unrelated to already-exists', function (): void { $client = new MockHttpClient(function (string $method, string $url): MockResponse { if (str_contains($url, 'listpkgs')) { return new MockResponse(json_encode(['package' => []])); } if (str_contains($url, 'addpkg')) { return new MockResponse(json_encode(['result' => [['status' => 0, 'statusmsg' => 'Invalid quota value']]])); } return new MockResponse(json_encode(['status' => 1])); }); $manager = createWhmManager($client); $account = createWhmAccount(new Server_Package(), false); // The WHM reason stays in the logs; API callers only see the generic message. expect(fn () => $manager->createAccount($account))->toThrow(Server_Exception::class, 'Failed to addpkg on the cPanel server'); }); test('addpkg already-exists does not leak the WHM reason to callers', function (): void { $client = new MockHttpClient(fn (string $method, string $url): MockResponse => new MockResponse(json_encode(['result' => [['status' => 0, 'statusmsg' => '(XID n25fcu) The package "admin_FOSSBilling" already exists.']]]))); $manager = createWhmManager($client); // Directly verify the typed exception carries the generic message, not the WHM reason. $ref = new ReflectionMethod($manager, 'request'); try { $ref->invoke($manager, 'addpkg', ['name' => 'admin_FOSSBilling']); expect(false)->toBeTrue('Expected Server_PackageExistsException was not thrown'); } catch (Server_PackageExistsException $e) { expect($e->getMessage())->not->toContain('already exists') ->and($e->getMessage())->toContain('Failed to addpkg'); } }); test('already-exists responses only use the package exception for addpkg', function (string $action, string $exceptionClass): void { $client = new MockHttpClient(fn (): MockResponse => new MockResponse(json_encode([ 'result' => [['status' => 0, 'statusmsg' => 'The requested resource already exists.']], ]))); $manager = createWhmManager($client); $ref = new ReflectionMethod($manager, 'request'); try { $ref->invoke($manager, $action); $this->fail('Expected a WHM exception'); } catch (Server_Exception $e) { expect($e::class)->toBe($exceptionClass) ->and($e->getMessage())->toContain('Failed to ' . $action) ->and($e->getMessage())->not->toContain('already exists'); } })->with([ 'package creation' => ['addpkg', Server_PackageExistsException::class], 'account creation' => ['createacct', Server_Exception::class], 'reseller setup' => ['setupreseller', Server_Exception::class], ]); test('WHM refuses invalid recovery steps without sending requests', function (string $step, bool $reseller): void { $client = new MockHttpClient(function (): never { $this->fail('Invalid recovery must not contact WHM'); }); $manager = createWhmManager($client); $account = createWhmAccount(new Server_Package(), $reseller); expect(fn () => $manager->resumeAccountProvisioning($account, $step))->toThrow(Server_Exception::class, 'Invalid WHM provisioning step'); })->with([ ['createacct', true], ['setupreseller', false], ]); test('WHM preserves the pending setup step when the transport fails', function (string $step): void { $client = new MockHttpClient(function (string $method, string $url): MockResponse { if (str_contains($url, 'listaccts')) { return new MockResponse(json_encode(['metadata' => ['result' => 1], 'data' => ['acct' => [['user' => 'example', 'domain' => 'example.com', 'owner' => 'admin', 'plan' => 'admin_FOSSBilling']]]])); } throw new Symfony\Component\HttpClient\Exception\TransportException('Connection lost'); }); $manager = createWhmManager($client); $account = createWhmAccount((new Server_Package())->setCustomValues(['acl' => 'reseller_basic']), true); try { $manager->resumeAccountProvisioning($account, $step); $this->fail('Expected a partial provisioning exception'); } catch (Server_PartialProvisioningException $e) { expect($e->nextStep)->toBe($step) ->and($e->getMessage())->not->toContain('Connection lost'); } })->with(['setupreseller', 'setacls']); test('WHM refuses to checkpoint an account that existed before provisioning', function (): void { $client = new MockHttpClient(fn (): MockResponse => new MockResponse(json_encode([ 'metadata' => ['result' => 1], 'data' => ['acct' => [['user' => 'example']]], ]))); $manager = createWhmManager($client); expect(fn () => $manager->prepareAccountCreation(createWhmAccount(new Server_Package(), true))) ->toThrow(Server_Exception::class, 'The hosting account already exists'); }); test('WHM does not resume provisioning from an unverified lookup', function (array $response, string $step): void { $client = new MockHttpClient(function (string $method, string $url, array $options) use ($response): MockResponse { expect(basename((string) parse_url($url, PHP_URL_PATH)))->toBe('listaccts'); parse_str((string) $options['body'], $params); expect($params)->toMatchArray(['search' => 'example', 'searchtype' => 'user', 'searchmethod' => 'exact']); return new MockResponse(json_encode($response)); }); $manager = createWhmManager($client); try { $manager->resumeAccountProvisioning(createWhmAccount(new Server_Package(), true), $step); $this->fail('Expected an account verification failure'); } catch (Server_Exception $e) { expect($e::class)->toBe(Server_Exception::class); } })->with([ 'permission denied' => [['metadata' => ['result' => 0, 'reason' => 'Permission denied']]], 'missing result' => [['data' => ['acct' => []]]], 'missing account list' => [['metadata' => ['result' => 1]]], 'invalid account entry' => [['metadata' => ['result' => 1], 'data' => ['acct' => [null]]]], 'different username' => [['metadata' => ['result' => 1], 'data' => ['acct' => [['user' => 'other', 'domain' => 'example.com', 'owner' => 'admin', 'plan' => 'admin_FOSSBilling']]]]], 'different domain' => [['metadata' => ['result' => 1], 'data' => ['acct' => [['user' => 'example', 'domain' => 'other.com', 'owner' => 'admin', 'plan' => 'admin_FOSSBilling']]]]], 'different owner' => [['metadata' => ['result' => 1], 'data' => ['acct' => [['user' => 'example', 'domain' => 'example.com', 'owner' => 'other', 'plan' => 'admin_FOSSBilling']]]]], 'different plan' => [['metadata' => ['result' => 1], 'data' => ['acct' => [['user' => 'example', 'domain' => 'example.com', 'owner' => 'admin', 'plan' => 'other']]]]], ])->with(['create', 'setupreseller', 'setacls']); test('WHM refuses reseller recovery when the account has disappeared', function (string $step): void { $actions = []; $client = new MockHttpClient(function (string $method, string $url) use (&$actions): MockResponse { $actions[] = basename((string) parse_url($url, PHP_URL_PATH)); return new MockResponse(json_encode(['metadata' => ['result' => 1], 'data' => ['acct' => []]])); }); $manager = createWhmManager($client); $account = createWhmAccount((new Server_Package())->setCustomValues(['acl' => 'reseller_basic']), true); try { $manager->resumeAccountProvisioning($account, $step); $this->fail('Expected an account verification failure'); } catch (Server_Exception $e) { expect($e::class)->toBe(Server_Exception::class) ->and($actions)->toBe(['listaccts']); } })->with(['setupreseller', 'setacls']);