Replace legacy hooks with typed Symfony events (#4400)

* Add typed Symfony events alongside legacy hooks

* Move admin login activity to typed event listener

* Move client login activity to typed event listener

* Add typed events for cron currency and notifications

* Add typed extension lifecycle events for widget cache

* Avoid duplicate typed listener registration

* Add typed events for settings subscriptions and client deletion

* Migrate core cron hooks to typed Symfony listeners

* Migrate support ticket hooks to typed events

* Migrate client and profile lifecycle hooks to typed events

* Migrate authentication hooks to typed events

* Migrate staff checkout and order lifecycle events

* Migrate cart, order batch, invoice, and staff reset hooks

* Migrate extension, invoice, system, and service hooks

* Retire legacy hook runtime and finish reminder events

* Clean up retired hook data during upgrades

* Clarify reminder events and complete gateway cancellation

* Document current typed event development model

* Apply Rector suggestions for typed event changes
This commit is contained in:
Adam Daley authored and GitHub committed 2026-09-24 01:19:05 +01:00
1 parent 77fffad293
commit 161f1328b8
227 files changed
+7303 -3358

No files matched your search

+42
View File
@@ -60,6 +60,48 @@ If you need to sell a new type of product you will implement a Service type modu
Other modules extend the whole FOSSBilling API with any functionality needed. Check existing modules to get an idea of what is already shipped with the default structure of FOSSBilling.
### Typed module events
FOSSBilling modules use typed events for extension points. Event classes extend `FOSSBilling\Events\Event`, and each module keeps its event classes in `src/modules/<Module>/Event/`. The shared base event is in `src/library/FOSSBilling/Events/Event.php`.
Define an event in the owning module's `Event/` directory and dispatch it from the code that owns the operation:
```php
namespace Box\Mod\Example\Event;
use FOSSBilling\Events\Event;
final class BeforeExampleOperationEvent extends Event
{
public function __construct(public readonly int $entityId)
{
}
}
```
```php
use Box\Mod\Example\Event\BeforeExampleOperationEvent;
$this->di['event_dispatcher']->dispatch(new BeforeExampleOperationEvent($entityId));
```
A module can listen from a public instance method on its `Service` class. Add Symfony's `#[AsEventListener]` attribute and type the first parameter as the event class. Listeners on active modules are registered when the dispatcher initializes.
```php
use Box\Mod\Support\Event\AfterTicketOpenedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(priority: 10)]
public function notifyTicketOpened(AfterTicketOpenedEvent $event): void
{
// Handle the opened ticket using the event's documented data.
}
```
Higher priority listeners run first. Event propagation follows Symfony's event behavior, including `stopPropagation()`. Listeners run synchronously as part of dispatch.
Treat each event class as the contract for its extension point. Prefer readonly constructor properties for context listeners may inspect. Keep mutable data private and expose only the getters and setters listeners are meant to use. Do not include credentials or unrelated request data in an event payload. Name event classes for the operation and timing, such as `Before...Event` or `After...Event`.
## How can I contribute?
There are a lot of different ways that you can get involved in the FOSSBilling project. Let's take a look at some of the main ones:
+1
View File
@@ -68,6 +68,7 @@
"stripe/stripe-php": "^21.0.0",
"symfony/cache": "^7.4.1",
"symfony/console": "^7.4.1",
"symfony/event-dispatcher": "^7.4",
"symfony/filesystem": "^7.4",
"symfony/finder": "^7.4",
"symfony/html-sanitizer": "^7.4",
Generated
+1 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "993c4fade814e1005b343443e7637c12",
"content-hash": "d2fa7f56bd094c9c59af05a9fbb588cb",
"packages": [
{
"name": "dflydev/dot-access-data",
+5 -12
View File
@@ -185,18 +185,11 @@ $di['mod_config'] = $di->protect(fn ($name) => $di['mod']($name)->getConfig());
$di['cookie_queue'] = fn (): FOSSBilling\Http\CookieQueue => new FOSSBilling\Http\CookieQueue();
/*
*
* @param void
*
* @return \Box_EventManager
*/
$di['events_manager'] = function () use ($di) {
$service = new Box_EventManager();
$service->setDi($di);
return $service;
};
$di['event_dispatcher'] = fn (): FOSSBilling\Events\EventDispatcher => new FOSSBilling\Events\EventDispatcher(
fn (): array => $di['mod_service']('extension')->getCoreAndActiveModules(),
fn (string $module): object => $di['mod_service']($module),
$di['logger']->withChannel('event'),
);
/*
* Creates a new session, applying specified security rules depending on the config.php settings.
+1 -1
View File
@@ -217,7 +217,7 @@ INSERT INTO `post` (`id`, `admin_id`, `title`, `description`, `content`, `slug`,
VALUES
(1,1,'FOSSBilling needs your donations','Donations are crucial for the success and sustainability of FOSSBilling and other open-source projects. We need your help.','Donations are crucial for the success and sustainability of FOSSBilling and other open-source projects. Without financial support from users and the broader community, it is difficult for these projects to continue to grow and evolve.\nDonations help to cover the costs of development and maintenance. Developing and maintaining software requires time and resources, and without financial support, it can be challenging for open-source projects to sustain their efforts.\n\nThe FOSSBilling project has expenses such as hosting, hardware, software, and other costs associated with developing and maintaining FOSSBilling and other open-source projects. Your donations also help cover some of the maintainers'' expenses.\nClick [here](https://fossbilling.org/donate) to donate to the FOSSBilling project.','fossbilling-needs-your-donations','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW()),
(2,1,'Check out great features of FOSSBilling','FOSSBilling supports automated billing, invoicing, product provisioning', '* Supports automated billing, invoicing, product provisioning\n* Automatically create accounts as soon as the payment is received, suspend when account becomes overdue, terminate when a specified amount of time passes.\n* FOSSBilling is perfectly created to sell shared and reseller hosting accounts, software licenses and downloadable products.\n* Integrated helpdesk, knowledgebase, news and announcements system.\n','great-features-of-fossbilling','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW()),
(3,1,'FOSSBilling is customizable','You can create your own simple or advanced hooks on FOSSBilling events.','* You can create your own simple or advanced hooks on FOSSBilling events. For example, send notification via sms when new client signs up.\n* Create custom theme for your client interface\n','fossbilling-is-customizable','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW());
(3,1,'FOSSBilling is customizable','You can extend FOSSBilling with typed event listeners.','* You can extend FOSSBilling with typed event listeners. For example, send an SMS notification when a new client signs up.\n* Create custom theme for your client interface\n','fossbilling-is-customizable','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW());
/*!40000 ALTER TABLE `post` ENABLE KEYS */;
UNLOCK TABLES;
-166
View File
@@ -1,166 +0,0 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2025 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
class Box_Event implements ArrayAccess, FOSSBilling\InjectionAwareInterface
{
protected ?Pimple\Container $di = null;
protected $value;
protected $processed = false;
/**
* Constructs a new sfEvent.
*
* @param mixed $subject The subject
* @param string $name The event name
* @param array $parameters An array of parameters
*/
public function __construct(protected mixed $subject, protected $name, protected $parameters = [], protected $api_admin = null, protected $api_guest = null)
{
}
public function setDi(Pimple\Container $di): void
{
$this->di = $di;
}
public function getDi(): ?Pimple\Container
{
return $this->di;
}
/**
* Returns the subject.
*
* @return mixed The subject
*/
public function getSubject()
{
return $this->subject;
}
public function getApiAdmin()
{
return $this->api_admin;
}
public function getApiGuest()
{
return $this->api_guest;
}
/**
* Returns the event name.
*
* @return string The event name
*/
public function getName()
{
return $this->name;
}
/**
* Sets the return value for this event.
*
* @param mixed $value The return value
*/
public function setReturnValue(mixed $value): void
{
$this->value = $value;
}
/**
* Returns the return value.
*
* @return mixed The return value
*/
public function getReturnValue()
{
return $this->value;
}
/**
* Sets the processed flag.
*
* @param bool $processed The processed flag value
*/
public function setProcessed($processed): void
{
$this->processed = (bool) $processed;
}
/**
* Returns whether the event has been processed by a listener or not.
*
* @return bool true if the event has been processed, false otherwise
*/
public function isProcessed()
{
return $this->processed;
}
/**
* Returns the event parameters.
*
* @return array The event parameters
*/
public function getParameters()
{
return $this->parameters;
}
/**
* Returns true if the parameter exists (implements the ArrayAccess interface).
*
* @param string $name The parameter name
*
* @return bool true if the parameter exists, false otherwise
*/
public function offsetExists(mixed $name): bool
{
return array_key_exists($name, $this->parameters);
}
/**
* Returns a parameter value (implements the ArrayAccess interface).
*
* @param string $name The parameter name
*
* @return mixed The parameter value
*/
public function offsetGet(mixed $name): mixed
{
if (!array_key_exists($name, $this->parameters)) {
throw new InvalidArgumentException("The event '{$this->name}' has no '{$name}' parameter.");
}
return $this->parameters[$name];
}
/**
* Sets a parameter (implements the ArrayAccess interface).
*
* @param string $name The parameter name
* @param mixed $value The parameter value
*/
public function offsetSet(mixed $name, mixed $value): void
{
$this->parameters[$name] = $value;
}
/**
* Removes a parameter (implements the ArrayAccess interface).
* (doesn't actually remove anything).
*
* @param string $name The parameter name
*/
public function offsetUnset(mixed $name): void
{
}
}
-92
View File
@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2025 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
class Box_EventDispatcher
{
protected $listeners = [];
/**
* Connects a listener to a given event name.
*
* @param string $name An event name
* @param mixed $listener A PHP callable
*/
public function connect($name, mixed $listener): void
{
$this->listeners[$name] ??= [];
$this->listeners[$name][] = $listener;
}
/**
* Notifies all listeners of a given event.
*
* @param Box_Event $event A Box_Event instance
*
* @return Box_Event The Box_Event instance
*/
public function notify(Box_Event $event)
{
foreach ($this->getListeners($event->getName()) as $listener) {
call_user_func($listener, $event);
}
return $event;
}
/**
* Filters a value by calling all listeners of a given event.
*
* @param Box_Event $event A Box_Event instance
* @param mixed $value The value to be filtered
*
* @return Box_Event The Box_Event instance
*/
public function filter(Box_Event $event, mixed $value)
{
foreach ($this->getListeners($event->getName()) as $listener) {
$value = call_user_func_array($listener, [$event, $value]);
}
$event->setReturnValue($value);
return $event;
}
/**
* Returns true if the given event name has some listeners.
*
* @param string $name The event name
*
* @return bool true if some listeners are connected, false otherwise
*/
public function hasListeners($name): bool
{
$this->listeners[$name] ??= [];
return (bool) FOSSBilling\Tools::safeCount($this->listeners[$name]);
}
/**
* Returns all listeners associated with a given event name.
*
* @param string $name The event name
*
* @return array An array of listeners
*/
public function getListeners($name)
{
if (!isset($this->listeners[$name])) {
return [];
}
return $this->listeners[$name];
}
}
-112
View File
@@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2025 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
class Box_EventManager implements FOSSBilling\InjectionAwareInterface
{
protected ?Pimple\Container $di = null;
/** @var array<string, list<array{rel_id: mixed, meta_value: mixed}>>|null */
private ?array $databaseListeners = null;
public const GLOBAL_LISTENER_NAME = 'onEveryEvent';
public function setDi(Pimple\Container $di): void
{
$this->di = $di;
}
public function getDi(): ?Pimple\Container
{
return $this->di;
}
public function fire($data)
{
if (!isset($data['event']) || empty($data['event'])) {
if ($this->di !== null && isset($this->di['logger'])) {
$this->di['logger']->warning('Invoked event call without providing event name');
}
return false;
}
$event = $data['event'];
$subject = $data['subject'] ?? null;
$params = $data['params'] ?? null;
$eventContext = ['event' => $event];
if (is_array($params)) {
$eventContext['parameters'] = $params;
}
$this->di['logger']->withChannel('event')->debug('Fired event: {event}', $eventContext);
$e = new Box_Event($subject, $event, $params);
$e->setDi($this->di);
$disp = new Box_EventDispatcher();
$eventName = $e->getName();
$this->connectDatabaseHooks($disp, $eventName);
$this->connectDatabaseHooks($disp, self::GLOBAL_LISTENER_NAME, $eventName); // Also connect global listeners (onEveryEvent) to the fired event
$disp->notify($e);
return $e->getReturnValue();
}
public function clearListenerCache(): void
{
$this->databaseListeners = null;
}
private function connectDatabaseHooks(Box_EventDispatcher $disp, string $event, ?string $dispatchEventName = null): void
{
$listeners = $this->getDatabaseListeners()[$event] ?? [];
foreach ($listeners as $listener) {
$mod = $listener['rel_id'];
$listenerEvent = $listener['meta_value'];
$dispatchEvent = $dispatchEventName ?? $listenerEvent;
try {
$service = $this->di['mod_service']($mod);
if (method_exists($service, $listenerEvent)) {
$disp->connect($dispatchEvent, [$service::class, $listenerEvent]);
}
} catch (Exception $e) {
$this->di['logger']->withChannel('event')->error($e->getMessage());
}
}
}
/** @return array<string, list<array{rel_id: mixed, meta_value: mixed}>> */
private function getDatabaseListeners(): array
{
if ($this->databaseListeners !== null) {
return $this->databaseListeners;
}
$sql = "SELECT rel_id, meta_value
FROM extension_meta
WHERE extension = 'mod_hook'
AND rel_type = 'mod'
AND meta_key = 'listener'
";
$listeners = $this->di['em']->getConnection()->fetchAllAssociative($sql);
$this->databaseListeners = [];
foreach ($listeners as $listener) {
$this->databaseListeners[(string) $listener['meta_value']][] = $listener;
}
return $this->databaseListeners;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace FOSSBilling\Events;
use Symfony\Contracts\EventDispatcher\Event as SymfonyEvent;
abstract class Event extends SymfonyEvent
{
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace FOSSBilling\Events;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher;
/**
* Registers listeners on active module services without requiring Symfony's DI container.
*/
final class EventDispatcher
{
private SymfonyEventDispatcher $dispatcher;
private bool $initialized = false;
/**
* @param \Closure(): array<string> $activeModules
* @param \Closure(string): object $moduleService
*/
public function __construct(
private readonly \Closure $activeModules,
private readonly \Closure $moduleService,
private readonly ?LoggerInterface $logger = null,
) {
$this->dispatcher = new SymfonyEventDispatcher();
}
public function dispatch(Event $event): Event
{
$this->initialize();
$this->logger?->debug('Dispatching event {event}', ['event' => $event::class]);
return $this->dispatcher->dispatch($event);
}
/**
* Rebuild listener registrations after a module is activated or deactivated.
*/
public function refresh(): void
{
$this->dispatcher = new SymfonyEventDispatcher();
$this->initialized = false;
}
private function initialize(): void
{
if ($this->initialized) {
return;
}
// Set this first so a listener's service setup cannot recursively scan modules.
$this->initialized = true;
try {
$services = [];
foreach (array_unique(($this->activeModules)()) as $moduleName) {
$className = 'Box\\Mod\\' . ucfirst($moduleName) . '\\Service';
if (!class_exists($className)) {
continue;
}
$reflection = new \ReflectionClass($className);
foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
foreach ($method->getAttributes(AsEventListener::class) as $attribute) {
$listener = $attribute->newInstance();
$parameterType = $this->eventType($method);
$eventName = $listener->event ?? $parameterType;
if ($eventName === null || !is_a($eventName, Event::class, true) || $parameterType === null || !is_a($eventName, $parameterType, true)) {
throw new \LogicException(sprintf('Listener %s::%s must accept its FOSSBilling event class.', $className, $method->getName()));
}
if ($listener->dispatcher !== null || ($listener->method !== null && $listener->method !== $method->getName())) {
throw new \LogicException(sprintf('Listener %s::%s uses unsupported AsEventListener options.', $className, $method->getName()));
}
$service = $services[$moduleName] ??= ($this->moduleService)($moduleName);
$this->dispatcher->addListener($eventName, [$service, $method->getName()], $listener->priority);
}
}
}
} catch (\Throwable $error) {
$this->refresh();
throw $error;
}
}
private function eventType(\ReflectionMethod $method): ?string
{
$type = $method->getParameters()[0]->getType() ?? null;
return $type instanceof \ReflectionNamedType && !$type->isBuiltin() ? $type->getName() : null;
}
}
@@ -22,7 +22,6 @@ class ExtensionManager implements InjectionAwareInterface
final public const string TYPE_PG = 'payment-gateway';
final public const string TYPE_SM = 'server-manager';
final public const string TYPE_DR = 'domain-registrar';
final public const string TYPE_HOOK = 'hook';
final public const string TYPE_TRANSLATION = 'translation';
private string $apiUrl = 'https://api.fossbilling.net/extensions/v1/';
+1 -1
View File
@@ -28,7 +28,7 @@ class Module implements InjectionAwareInterface
public const CORE_MODULES = ['api', 'activity', 'antispam', 'cart', 'client',
'cron', 'currency', 'email', 'extension',
'hook', 'index', 'invoice', 'order',
'index', 'invoice', 'order',
'page', 'product', 'profile', 'security',
'servicecustom', 'servicedomain', 'servicedownloadable',
'servicehosting', 'servicelicense', 'staff', 'stats',
-1
View File
@@ -51,7 +51,6 @@ class SentryHelper
'embed',
'extension',
'formbuilder',
'hook',
'index',
'invoice',
'massmailer',
+11 -7
View File
@@ -193,10 +193,6 @@ class UpdatePatcher implements InjectionAwareInterface
call_user_func($patch);
$this->setPatchLevel($patchLevel);
}
if ($patches !== [] && $this->di !== null && $this->di->offsetExists('events_manager')) {
$this->di['events_manager']->clearListenerCache();
}
}
// Portable (plain UPDATE ... WHERE, no MySQL-specific syntax) and idempotent, so it
@@ -206,6 +202,10 @@ class UpdatePatcher implements InjectionAwareInterface
// forever.
$this->migrateThemePackageLayout();
// Retired hook packages and listener registrations have no runtime consumer. Remove
// their records on every driver so old installs do not retain invisible extensions.
$this->removeRetiredHookData();
// Same treatment for the debit-note settings rows content.sql seeds for fresh installs:
// plain check-then-insert SQL, idempotent, so every platform gets them even though no
// MySQL-only patch can run there.
@@ -1529,9 +1529,6 @@ class UpdatePatcher implements InjectionAwareInterface
$this->executeSql("DELETE FROM extension_meta WHERE extension = 'mod_hook' AND rel_type = 'mod' AND rel_id = 'spamchecker' AND meta_key = 'listener'");
$hookService = $this->di['mod_service']('hook');
$hookService->batchConnect('antispam');
$this->executeSql("DELETE FROM extension_meta WHERE extension = 'mod_spamchecker' AND meta_key = 'config'");
$spamcheckerExt = $extService->getExtensionRepository()->findOneByTypeAndName('mod', 'spamchecker');
@@ -3189,6 +3186,13 @@ class UpdatePatcher implements InjectionAwareInterface
return ['invoices' => $repairedInvoices, 'notifications' => $repairedNotes];
}
/** Remove obsolete listener registrations and standalone hook-package records. */
private function removeRetiredHookData(): void
{
$this->executeSql("DELETE FROM extension_meta WHERE extension = 'mod_hook' AND meta_key = 'listener'");
$this->executeSql("DELETE FROM extension WHERE type = 'hook'");
}
/**
* Bundles the shipped themes into one package: admin_default -> default/admin,
* huraga -> default/client. Third-party themes are untouched.
+23 -28
View File
@@ -17,10 +17,14 @@ use Box\Mod\Activity\Entity\ActivitySystem;
use Box\Mod\Activity\Repository\ActivityClientHistoryRepository;
use Box\Mod\Activity\Repository\ActivitySystemRepository;
use Box\Mod\Client\Entity\Client;
use Box\Mod\Client\Event\AfterClientLoginEvent;
use Box\Mod\Cron\Event\BeforeAdminCronRunEvent;
use Box\Mod\Staff\Event\AfterAdminLoginEvent;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use FOSSBilling\InjectionAwareInterface;
use FOSSBilling\SortOptions;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
class Service implements InjectionAwareInterface
{
@@ -58,11 +62,6 @@ class Service implements InjectionAwareInterface
return $this->di['dbal'];
}
private static function getDbalFromDi(\Pimple\Container $di): Connection
{
return $di['dbal'];
}
public function logEvent($data): void
{
$extensionService = $this->di['mod_service']('extension');
@@ -81,42 +80,38 @@ class Service implements InjectionAwareInterface
$this->di['em']->flush();
}
public static function onAfterClientLogin(\Box_Event $event): void
#[AsEventListener]
public function recordClientLogin(AfterClientLoginEvent $event): void
{
$params = $event->getParameters();
$di = $event->getDi();
$extensionService = $di['mod_service']('extension');
$ip = $extensionService->isExtensionActive('mod', 'demo') ? null : $params['ip'];
$extensionService = $this->di['mod_service']('extension');
$ip = $extensionService->isExtensionActive('mod', 'demo') ? null : $event->ip;
$history = (new ActivityClientHistory())
->setClientId((int) $params['id'])
->setClientId($event->clientId)
->setIp($ip);
$di['em']->persist($history);
$di['em']->flush();
$this->di['em']->persist($history);
$this->di['em']->flush();
}
public static function onAfterAdminLogin(\Box_Event $event): void
#[AsEventListener]
public function recordAdminLogin(AfterAdminLoginEvent $event): void
{
$params = $event->getParameters();
$di = $event->getDi();
$extensionService = $di['mod_service']('extension');
$ip = $extensionService->isExtensionActive('mod', 'demo') ? null : $params['ip'];
$extensionService = $this->di['mod_service']('extension');
$ip = $extensionService->isExtensionActive('mod', 'demo') ? null : $event->ip;
$history = (new ActivityAdminHistory())
->setAdminId((int) $params['id'])
->setAdminId($event->adminId)
->setIp($ip);
$di['em']->persist($history);
$di['em']->flush();
$this->di['em']->persist($history);
$this->di['em']->flush();
}
public static function onBeforeAdminCronRun(\Box_Event $event): void
#[AsEventListener]
public function cleanupOldActivity(BeforeAdminCronRunEvent $event): void
{
$di = $event->getDi();
$config = $di['mod_service']('extension')->getConfig('mod_activity');
$config = $this->di['mod_service']('extension')->getConfig('mod_activity');
$retention = intval($config['max_age'] ?? 90);
$emailRetention = intval($config['email_max_age'] ?? 0);
@@ -127,7 +122,7 @@ class Service implements InjectionAwareInterface
$ageInSeconds = $retention * 86_400;
$emailAgeInSeconds = $emailRetention * 86_400;
$dbal = self::getDbalFromDi($di);
$dbal = $this->getDbal();
try {
if ($retention !== 0) {
@@ -143,7 +138,7 @@ class Service implements InjectionAwareInterface
]);
}
} catch (\Exception $e) {
$di['logger']->error($e->getMessage());
$this->di['logger']->error($e->getMessage());
}
}
+41 -17
View File
@@ -16,6 +16,9 @@ use Box\Mod\Activity\Entity\ActivitySystem;
use Box\Mod\Activity\Repository\ActivityClientHistoryRepository;
use Box\Mod\Activity\Repository\ActivitySystemRepository;
use Box\Mod\Client\Entity\Client;
use Box\Mod\Client\Event\AfterClientLoginEvent;
use Box\Mod\Cron\Event\BeforeAdminCronRunEvent;
use Box\Mod\Staff\Event\AfterAdminLoginEvent;
use Doctrine\ORM\EntityManagerInterface;
use function Tests\Helpers\container;
@@ -105,13 +108,13 @@ test('log event persists a system activity entity', function (): void {
->and($persisted->getMessage())->toBe('Test event');
});
test('login events persist history entities', function (string $method, string $entityClass, string $idGetter): void {
test('typed client login event persists history', function (): void {
$persisted = null;
$entityManager = Mockery::mock(EntityManagerInterface::class);
$entityManager->shouldReceive('persist')->once()->withArgs(function (object $history) use (&$persisted, $entityClass): bool {
$entityManager->shouldReceive('persist')->once()->withArgs(function (ActivityClientHistory $history) use (&$persisted): bool {
$persisted = $history;
return $history instanceof $entityClass;
return true;
});
$entityManager->shouldReceive('flush')->once();
@@ -122,18 +125,40 @@ test('login events persist history entities', function (string $method, string $
$di['em'] = $entityManager;
$di['mod_service'] = $di->protect(fn (): object => $extensionService);
$event = new Box_Event(null, $method, ['id' => 7, 'ip' => '192.0.2.1']);
$event->setDi($di);
$service = new Box\Mod\Activity\Service();
$service->setDi($di);
$service->recordClientLogin(new AfterClientLoginEvent(7, '192.0.2.1'));
Box\Mod\Activity\Service::{$method}($event);
expect($persisted)->toBeInstanceOf($entityClass)
->and($persisted->{$idGetter}())->toBe(7)
expect($persisted)->toBeInstanceOf(ActivityClientHistory::class)
->and($persisted->getClientId())->toBe(7)
->and($persisted->getIp())->toBe('192.0.2.1');
})->with([
['onAfterClientLogin', ActivityClientHistory::class, 'getClientId'],
['onAfterAdminLogin', ActivityAdminHistory::class, 'getAdminId'],
]);
});
test('typed admin login event persists history', function (): void {
$persisted = null;
$entityManager = Mockery::mock(EntityManagerInterface::class);
$entityManager->shouldReceive('persist')->once()->withArgs(function (ActivityAdminHistory $history) use (&$persisted): bool {
$persisted = $history;
return true;
});
$entityManager->shouldReceive('flush')->once();
$extensionService = Mockery::mock(Box\Mod\Extension\Service::class);
$extensionService->shouldReceive('isExtensionActive')->once()->with('mod', 'demo')->andReturnFalse();
$di = container();
$di['em'] = $entityManager;
$di['mod_service'] = $di->protect(fn (): object => $extensionService);
$service = new Box\Mod\Activity\Service();
$service->setDi($di);
$service->recordAdminLogin(new AfterAdminLoginEvent(7, '192.0.2.1'));
expect($persisted)->toBeInstanceOf(ActivityAdminHistory::class)
->and($persisted->getAdminId())->toBe(7)
->and($persisted->getIp())->toBe('192.0.2.1');
});
test('logs retention failures through the application logger', function (): void {
$dbal = Mockery::mock(Doctrine\DBAL\Connection::class);
@@ -153,10 +178,9 @@ test('logs retention failures through the application logger', function (): void
$di['logger'] = $logger;
$di['mod_service'] = $di->protect(fn (string $name): object => $extensionService);
$event = new Box_Event(null, 'onBeforeAdminCronRun');
$event->setDi($di);
Box\Mod\Activity\Service::onBeforeAdminCronRun($event);
$service = new Box\Mod\Activity\Service();
$service->setDi($di);
$service->cleanupOldActivity(new BeforeAdminCronRunEvent());
expect($logger->calls)->toContain([
'method' => 'error',
+50 -55
View File
@@ -11,9 +11,16 @@ declare(strict_types=1);
namespace Box\Mod\Antispam;
use Box\Mod\Client\Event\BeforeAdminClientUpdateEvent;
use Box\Mod\Client\Event\BeforeClientLoginEvent;
use Box\Mod\Client\Event\BeforeClientSignUpEvent;
use Box\Mod\Profile\Event\BeforeClientProfileUpdateEvent;
use Box\Mod\Staff\Event\BeforeAdminLoginEvent;
use Box\Mod\Support\Event\BeforeGuestTicketCreateEvent;
use EmailChecker\Adapter;
use EmailChecker\Utilities;
use FOSSBilling\InjectionAwareInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
use Symfony\Contracts\Cache\ItemInterface;
@@ -58,72 +65,64 @@ class Service implements InjectionAwareInterface
];
}
public static function onBeforeClientSignUp(\Box_Event $event): void
#[AsEventListener]
public function onBeforeClientSignUp(BeforeClientSignUpEvent $event): void
{
// Client\Api\Guest::create() already verifies the CAPTCHA before firing
// this event, so isSpam() below must not check it again: CAPTCHA
// tokens are single-use, and a second siteverify call would fail and
// reject every genuine signup.
$di = $event->getDi();
$antispamService = $di['mod_service']('Antispam');
$antispamService->isBlockedIp($event);
$antispamService->isSpam($event);
$antispamService->isTemp($event);
$antispamService->checkHoneypot($event);
// this event. CAPTCHA tokens are single-use, so checking it again here
// would reject every genuine signup.
$di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.');
$this->checkBlockedIp($di);
$this->checkSpam($di, $event->input);
$this->checkTempEmail($di, $event->input);
$this->checkHoneypot($di, $event->input);
}
public static function onBeforeClientOpenTicket(\Box_Event $event): void
#[AsEventListener]
public function onBeforeGuestTicketCreate(BeforeGuestTicketCreateEvent $event): void
{
if ($event->getParameters()['author_role'] !== 'guest') {
if (($event->input['author_role'] ?? null) !== 'guest') {
return;
}
$di = $event->getDi();
$antispamService = $di['mod_service']('Antispam');
$antispamService->isBlockedIp($event);
$antispamService->checkCaptcha($event->getParameters());
$antispamService->isSpam($event);
$antispamService->isTemp($event);
$di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.');
$this->checkBlockedIp($di);
$this->checkCaptcha($event->input);
$this->checkSpam($di, $event->input);
$this->checkTempEmail($di, $event->input);
}
public static function onBeforeClientProfileUpdate(\Box_Event $event): void
#[AsEventListener]
public function onBeforeClientProfileUpdate(BeforeClientProfileUpdateEvent $event): void
{
$di = $event->getDi();
$antispamService = $di['mod_service']('Antispam');
$antispamService->isBlockedIp($event);
$di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.');
$this->checkBlockedIp($di);
}
public static function onBeforeAdminClientUpdate(\Box_Event $event): void
#[AsEventListener]
public function onBeforeAdminClientUpdate(BeforeAdminClientUpdateEvent $event): void
{
$di = $event->getDi();
$antispamService = $di['mod_service']('Antispam');
$antispamService->isBlockedIp($event);
$di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.');
$this->checkBlockedIp($di);
}
public static function onBeforeClientUpdate(\Box_Event $event): void
#[AsEventListener]
public function onBeforeClientLogin(BeforeClientLoginEvent $event): void
{
$di = $event->getDi();
$antispamService = $di['mod_service']('Antispam');
$antispamService->isBlockedIp($event);
$di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.');
$this->checkBlockedIp($di);
}
public static function onBeforeClientLogin(\Box_Event $event): void
#[AsEventListener]
public function onBeforeAdminLogin(BeforeAdminLoginEvent $event): void
{
$di = $event->getDi();
$antispamService = $di['mod_service']('Antispam');
$antispamService->isBlockedIp($event);
$di = $this->di ?? throw new \LogicException('Antispam service must be initialized before handling events.');
$this->checkBlockedIp($di);
}
public static function onBeforeAdminLogin(\Box_Event $event): void
private function checkBlockedIp(\Pimple\Container $di): void
{
$di = $event->getDi();
$antispamService = $di['mod_service']('Antispam');
$antispamService->isBlockedIp($event);
}
public function isBlockedIp(\Box_Event $event): void
{
$di = $event->getDi();
$config = $di['mod_config']('Antispam');
if (isset($config['block_ips']) && $config['block_ips'] && isset($config['blocked_ips'])) {
$blocked_ips = explode(PHP_EOL, $config['blocked_ips']);
@@ -134,11 +133,9 @@ class Service implements InjectionAwareInterface
}
}
public function isSpam(\Box_Event $event): void
/** @param array<string, mixed> $params */
private function checkSpam(\Pimple\Container $di, array $params): void
{
$di = $event->getDi();
$params = $event->getParameters();
$data = [
'ip' => $params['ip'] ?? null,
'email' => $params['email'] ?? null,
@@ -252,33 +249,31 @@ class Service implements InjectionAwareInterface
}
}
public function isTemp(\Box_Event $event): void
/** @param array<string, mixed> $params */
private function checkTempEmail(\Pimple\Container $di, array $params): void
{
$di = $event->getDi();
$config = $di['mod_config']('Antispam');
$check = $config['check_temp_emails'] ?? true;
if ($check) {
$antispamService = $di['mod_service']('Antispam');
$params = $event->getParameters();
$email = $params['email'] ?? '';
$antispamService = $di['mod_service']('Antispam');
$antispamService->isATempEmail($email, true);
}
}
public function checkHoneypot(\Box_Event $event): void
/** @param array<string, mixed> $params */
private function checkHoneypot(\Pimple\Container $di, array $params): void
{
$di = $event->getDi();
$config = $di['mod_config']('Antispam');
$enabled = $config['honeypot_enabled'] ?? true;
if ($enabled) {
$params = $event->getParameters();
$honeypotField = $config['honeypot_field'] ?? 'bio';
if (!empty($params[$honeypotField])) {
$this->di['logger']->info('Potential spam registration blocked. Reason: honeypot field was not empty.');
$di['logger']->info('Potential spam registration blocked. Reason: honeypot field was not empty.');
throw new \FOSSBilling\InformationException('Registration failed.');
}
+152 -78
View File
@@ -10,6 +10,12 @@
declare(strict_types=1);
use Box\Mod\Client\Event\BeforeAdminClientUpdateEvent;
use Box\Mod\Client\Event\BeforeClientLoginEvent;
use Box\Mod\Client\Event\BeforeClientSignUpEvent;
use Box\Mod\Profile\Event\BeforeClientProfileUpdateEvent;
use Box\Mod\Staff\Event\BeforeAdminLoginEvent;
use Box\Mod\Support\Event\BeforeGuestTicketCreateEvent;
use Symfony\Component\HttpFoundation\Request;
use function Tests\Helpers\container;
@@ -22,137 +28,205 @@ test('dependency injection', function (): void {
expect($getDi)->toEqual($di);
});
test('on before client sign up', function (): void {
$service = new Box\Mod\Antispam\Service();
test('before client signup checks spam and disposable email without re-verifying captcha', function (): void {
$input = [
'ip' => '1.2.3.4',
'email' => 'test@example.com',
'bio' => '',
'g-recaptcha-response' => 'already-verified-token',
];
$spamCheckerService = Mockery::mock(Box\Mod\Antispam\Service::class);
$spamCheckerService->shouldReceive('isBlockedIp')
->atLeast()->once();
$spamCheckerService->shouldReceive('isSpam')
->atLeast()->once();
$spamCheckerService->shouldReceive('isTemp')
->atLeast()->once();
$spamCheckerService->shouldReceive('checkHoneypot')
->atLeast()->once();
$spamCheckerService->shouldReceive('isInStopForumSpamDatabase')
->once()
->with(['ip' => $input['ip'], 'email' => $input['email']])
->ordered()
->andReturnFalse();
$spamCheckerService->shouldReceive('isATempEmail')
->once()
->with($input['email'], true)
->ordered()
->andReturnFalse();
$service = Mockery::mock(Box\Mod\Antispam\Service::class)->makePartial();
$service->shouldReceive('checkCaptcha')->never();
$di = container();
$di['mod_config'] = $di->protect(fn (): array => [
'block_ips' => false,
'sfs' => true,
'check_temp_emails' => true,
'honeypot_enabled' => true,
'captcha_enabled' => true,
]);
$di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => $input['ip']]);
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $spamCheckerService);
$boxEventMock = Mockery::mock('\Box_Event');
$boxEventMock->shouldReceive('getDi')
->atLeast()->once()
->andReturn($di);
$service->setDi($di);
$service->onBeforeClientSignUp($boxEventMock);
$service->onBeforeClientSignUp(new BeforeClientSignUpEvent($input));
});
test('on before client open ticket checks guest submissions', function (): void {
test('before client profile update rejects a blocked IP', function (): void {
$service = new Box\Mod\Antispam\Service();
$spamCheckerService = Mockery::mock(Box\Mod\Antispam\Service::class);
$spamCheckerService->shouldReceive('isBlockedIp')
->atLeast()->once();
$spamCheckerService->shouldReceive('checkCaptcha')
->once();
$spamCheckerService->shouldReceive('isSpam')
->atLeast()->once();
$spamCheckerService->shouldReceive('isTemp')
->atLeast()->once();
$di = container();
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $spamCheckerService);
$boxEventMock = Mockery::mock('\Box_Event');
$boxEventMock->shouldReceive('getDi')
->atLeast()->once()
->andReturn($di);
$boxEventMock->shouldReceive('getParameters')
->atLeast()->once()
->andReturn(['author_role' => 'guest', 'email' => 'guest@example.com']);
$di['mod_config'] = $di->protect(fn (): array => [
'block_ips' => true,
'blocked_ips' => '1.2.3.4',
]);
$di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']);
$service->setDi($di);
$service->onBeforeClientOpenTicket($boxEventMock);
expect(fn () => $service->onBeforeClientProfileUpdate(new BeforeClientProfileUpdateEvent(42, [])))
->toThrow(FOSSBilling\InformationException::class, 'Your IP address (1.2.3.4) is blocked');
});
test('on before client open ticket skips client submissions', function (): void {
test('before admin client update rejects a blocked IP', function (): void {
$service = new Box\Mod\Antispam\Service();
$spamCheckerService = Mockery::mock(Box\Mod\Antispam\Service::class);
$spamCheckerService->shouldReceive('isBlockedIp')->never();
$spamCheckerService->shouldReceive('checkCaptcha')->never();
$spamCheckerService->shouldReceive('isSpam')->never();
$spamCheckerService->shouldReceive('isTemp')->never();
$di = container();
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $spamCheckerService);
$boxEventMock = Mockery::mock('\Box_Event');
$boxEventMock->shouldReceive('getParameters')
->atLeast()->once()
->andReturn(['author_role' => 'client', 'client_id' => 1]);
$di['mod_config'] = $di->protect(fn (): array => [
'block_ips' => true,
'blocked_ips' => '1.2.3.4',
]);
$di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']);
$service->setDi($di);
$service->onBeforeClientOpenTicket($boxEventMock);
expect(fn () => $service->onBeforeAdminClientUpdate(new BeforeAdminClientUpdateEvent(42, [])))
->toThrow(FOSSBilling\InformationException::class, 'Your IP address (1.2.3.4) is blocked');
});
test('is spam does not re-verify the captcha during signup', function (): void {
// The signup API already verifies the CAPTCHA before this event fires;
// isSpam() re-checking it would fail every signup since CAPTCHA tokens
// are single-use.
test('before guest ticket creation checks blocked IP, captcha, spam, and disposable email', function (): void {
$input = [
'author_role' => 'guest',
'email' => 'guest@example.com',
'ip' => '1.2.3.4',
'g-recaptcha-response' => 'token',
];
$service = Mockery::mock(Box\Mod\Antispam\Service::class)->makePartial();
$service->shouldReceive('checkCaptcha')->once()->with($input);
$service->shouldReceive('isInStopForumSpamDatabase')
->once()
->with(['ip' => '1.2.3.4', 'email' => 'guest@example.com'])
->andReturnTrue();
$service->shouldReceive('isATempEmail')
->once()
->with('guest@example.com', true)
->andReturnFalse();
$di = container();
$di['mod_config'] = $di->protect(fn (): array => [
'block_ips' => false,
'sfs' => true,
'check_temp_emails' => true,
]);
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $service);
$service->setDi($di);
$event = new BeforeGuestTicketCreateEvent($input, 'open', 'subject', 'message');
$service->onBeforeGuestTicketCreate($event);
expect($event->getStatus())->toBe('open')
->and($event->getSubject())->toBe('subject')
->and($event->getMessage())->toBe('message');
});
test('before guest ticket creation skips non-guest submissions', function (): void {
$service = Mockery::mock(Box\Mod\Antispam\Service::class)->makePartial();
$service->shouldReceive('checkCaptcha')->never();
$service->shouldReceive('isInStopForumSpamDatabase')->never();
$service->shouldReceive('isATempEmail')->never();
$di = container();
$di['mod_config'] = $di->protect(fn (): array => [
'block_ips' => true,
'blocked_ips' => '1.2.3.4',
'sfs' => true,
'check_temp_emails' => true,
]);
$di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']);
$service->setDi($di);
$service->onBeforeGuestTicketCreate(new BeforeGuestTicketCreateEvent(
['author_role' => 'client', 'client_id' => 1],
'open',
'subject',
'message',
));
});
test('before guest ticket creation propagates blocked IP exceptions', function (): void {
$service = Mockery::mock(Box\Mod\Antispam\Service::class)->makePartial();
$service->shouldReceive('checkCaptcha')->never();
$di = container();
$di['mod_config'] = $di->protect(fn (): array => ['sfs' => false]);
$di['mod_config'] = $di->protect(fn (): array => [
'block_ips' => true,
'blocked_ips' => '1.2.3.4',
]);
$di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']);
$service->setDi($di);
$boxEventMock = Mockery::mock('\Box_Event');
$boxEventMock->shouldReceive('getDi')
->atLeast()->once()
->andReturn($di);
$boxEventMock->shouldReceive('getParameters')
->atLeast()->once()
->andReturn(['ip' => '1.2.3.4', 'email' => 'test@example.com']);
$service->isSpam($boxEventMock);
expect(fn () => $service->onBeforeGuestTicketCreate(new BeforeGuestTicketCreateEvent(
['author_role' => 'guest', 'email' => 'guest@example.com'],
'open',
'subject',
'message',
)))->toThrow(FOSSBilling\InformationException::class, 'Your IP address (1.2.3.4) is blocked');
});
test('is blocked ip ip not blocked', function (): void {
test('client login allows an IP absent from the block list', function (): void {
$service = new Box\Mod\Antispam\Service();
$clientIp = '214.1.4.99';
$modConfig = [
'block_ips' => true,
'blocked_ips' => '1.1.1.1' . PHP_EOL . '2.2.2.2',
];
$di = container();
$di['request'] = Request::createFromGlobals();
$di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '214.1.4.99']);
$di['mod_config'] = $di->protect(function ($modName) use ($modConfig) {
if ($modName == 'Antispam') {
return $modConfig;
}
});
$service->setDi($di);
$boxEventMock = Mockery::mock('\Box_Event');
$boxEventMock->shouldReceive('getDi')
->atLeast()->once()
->andReturn($di);
$service->isBlockedIp($boxEventMock);
expect(fn () => $service->onBeforeClientLogin(new BeforeClientLoginEvent('214.1.4.99')))
->not->toThrow(Throwable::class);
});
test('is blocked ip block ips not enabled', function (): void {
test('admin login allows an IP when blocking is disabled', function (): void {
$service = new Box\Mod\Antispam\Service();
$modConfig = [
'block_ips' => false,
];
$di = container();
$di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']);
$di['mod_config'] = $di->protect(function ($modName) use ($modConfig) {
if ($modName == 'Antispam') {
return $modConfig;
}
});
$service->setDi($di);
$boxEventMock = Mockery::mock('\Box_Event');
$boxEventMock->shouldReceive('getDi')
->atLeast()->once()
->andReturn($di);
$service->isBlockedIp($boxEventMock);
expect(fn () => $service->onBeforeAdminLogin(new BeforeAdminLoginEvent('1.2.3.4')))
->not->toThrow(Throwable::class);
});
test('login listeners reject a blocked IP', function (string $listener, string $eventClass): void {
$service = new Box\Mod\Antispam\Service();
$di = container();
$di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '1.2.3.4']);
$di['mod_config'] = $di->protect(fn (): array => [
'block_ips' => true,
'blocked_ips' => '1.2.3.4',
]);
$service->setDi($di);
expect(fn () => $service->{$listener}(new $eventClass('1.2.3.4')))
->toThrow(FOSSBilling\InformationException::class, 'Your IP address (1.2.3.4) is blocked');
})->with([
['onBeforeClientLogin', BeforeClientLoginEvent::class],
['onBeforeAdminLogin', BeforeAdminLoginEvent::class],
]);
dataset('spam responses', fn (): array => [
[
'{"success" : "true", "username" : {"appears" : "true" }}', 'Your username is blacklisted in the Stop Forum Spam database',
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Cart\Event;
use FOSSBilling\Events\Event;
/** Dispatched after a product and its bundled cart items have been added. */
final class AfterProductAddedToCartEvent extends Event
{
public function __construct(
public readonly int $cartId,
public readonly int $productId,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Cart\Event;
use FOSSBilling\Events\Event;
final class AfterStaffOrderCreateEvent extends Event
{
public function __construct(
public readonly int $adminId,
public readonly int $clientId,
public readonly int $orderId,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Cart\Event;
use FOSSBilling\Events\Event;
final class BeforeClientCheckoutEvent extends Event
{
public function __construct(
public readonly int $cartId,
public readonly int $clientId,
public readonly ?string $ip,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Cart\Event;
use FOSSBilling\Events\Event;
/** Dispatched before a product and its bundled cart items are added. */
final class BeforeProductAddedToCartEvent extends Event
{
public function __construct(
public readonly int $cartId,
public readonly int $productId,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Cart\Event;
use FOSSBilling\Events\Event;
final class BeforeStaffCheckoutEvent extends Event
{
public function __construct(
public readonly int $adminId,
public readonly int $clientId,
public readonly int $cartId,
) {
}
}
+30 -43
View File
@@ -13,12 +13,18 @@ namespace Box\Mod\Cart;
use Box\Mod\Cart\Entity\Cart;
use Box\Mod\Cart\Entity\CartProduct;
use Box\Mod\Cart\Event\AfterProductAddedToCartEvent;
use Box\Mod\Cart\Event\AfterStaffOrderCreateEvent;
use Box\Mod\Cart\Event\BeforeClientCheckoutEvent;
use Box\Mod\Cart\Event\BeforeProductAddedToCartEvent;
use Box\Mod\Cart\Event\BeforeStaffCheckoutEvent;
use Box\Mod\Cart\Repository\CartProductRepository;
use Box\Mod\Cart\Repository\CartRepository;
use Box\Mod\Client\Entity\Client;
use Box\Mod\Currency\Entity\Currency;
use Box\Mod\Invoice\Entity\Invoice;
use Box\Mod\Order\Entity\Order;
use Box\Mod\Order\Event\AfterClientOrderCreateEvent;
use Box\Mod\Product\Entity\Product;
use Box\Mod\Product\Entity\Promo;
use Box\Mod\Product\Service as ProductService;
@@ -223,8 +229,9 @@ class Service implements InjectionAwareInterface
throw new \FOSSBilling\InformationException('Price override cannot be negative');
}
$event_params = [...$data, 'cart_id' => $cart->getId(), 'product_id' => $this->getProductId($product)];
$this->di['events_manager']->fire(['event' => 'onBeforeProductAddedToCart', 'params' => $event_params]);
$cartId = (int) $cart->getId();
$productId = $this->getProductId($product);
$this->di['event_dispatcher']->dispatch(new BeforeProductAddedToCartEvent($cartId, $productId));
$productService = $this->getProductService()->getProductModuleService($product);
@@ -347,7 +354,7 @@ class Service implements InjectionAwareInterface
$this->di['logger']->info('Added "{product_title}" to shopping cart', ['product_title' => $this->getProductTitle($product)]);
$this->di['events_manager']->fire(['event' => 'onAfterProductAddedToCart', 'params' => $event_params]);
$this->di['event_dispatcher']->dispatch(new AfterProductAddedToCartEvent($cartId, $productId));
return true;
}
@@ -887,16 +894,11 @@ class Service implements InjectionAwareInterface
$this->assertPromoCartConditionMet($cart, $promo);
}
$this->di['events_manager']->fire(
[
'event' => 'onBeforeClientCheckout',
'params' => [
'ip' => $this->di['request']->getClientIp(),
'client_id' => (int) $client->getId(),
'cart_id' => $cart->getId(),
],
]
);
$this->di['event_dispatcher']->dispatch(new BeforeClientCheckoutEvent(
(int) $cart->getId(),
(int) $client->getId(),
$this->di['request']->getClientIp(),
));
[$order, $invoice, $orders] = $this->createFromCart($client, $gateway_id);
@@ -904,16 +906,11 @@ class Service implements InjectionAwareInterface
$this->di['logger']->info('Checked out shopping cart');
$this->di['events_manager']->fire(
[
'event' => 'onAfterClientOrderCreate',
'params' => [
'ip' => $this->di['request']->getClientIp(),
'client_id' => (int) $client->getId(),
'id' => $order->getId(),
],
]
);
$this->di['event_dispatcher']->dispatch(new AfterClientOrderCreateEvent(
(int) $order->getId(),
(int) $client->getId(),
$this->di['request']->getClientIp(),
));
$result = [
'gateway_id' => $gateway_id,
@@ -971,16 +968,11 @@ class Service implements InjectionAwareInterface
$this->assertPromoCartConditionMet($basket, $promo);
}
$this->di['events_manager']->fire(
[
'event' => 'onBeforeStaffCheckout',
'params' => [
'admin_id' => $adminId,
'client_id' => (int) $client->getId(),
'cart_id' => $basket->getId(),
],
]
);
$this->di['event_dispatcher']->dispatch(new BeforeStaffCheckoutEvent(
$adminId,
(int) $client->getId(),
(int) $basket->getId(),
));
[$order, $invoice, $orders] = $this->createOrdersFromCart($basket, $client, [
'gateway_id' => $options['gateway_id'] ?? null,
@@ -992,16 +984,11 @@ class Service implements InjectionAwareInterface
$this->di['logger']->info('Checked out staff basket for client #{client_id}', ['client_id' => $client->getId()]);
$this->di['events_manager']->fire(
[
'event' => 'onAfterStaffOrderCreate',
'params' => [
'admin_id' => $adminId,
'client_id' => (int) $client->getId(),
'id' => $order->getId(),
],
]
);
$this->di['event_dispatcher']->dispatch(new AfterStaffOrderCreateEvent(
$adminId,
(int) $client->getId(),
(int) $order->getId(),
));
$result = [
'gateway_id' => $options['gateway_id'] ?? null,
+92 -41
View File
@@ -12,6 +12,8 @@ declare(strict_types=1);
use Box\Mod\Cart\Entity\Cart;
use Box\Mod\Cart\Entity\CartProduct;
use Box\Mod\Cart\Event\AfterProductAddedToCartEvent;
use Box\Mod\Cart\Event\BeforeProductAddedToCartEvent;
use Box\Mod\Cart\Repository\CartProductRepository;
use Box\Mod\Cart\Repository\CartRepository;
use Box\Mod\Cart\Service;
@@ -25,6 +27,7 @@ use Box\Mod\Product\Entity\Product;
use Box\Mod\Product\Entity\Promo;
use Box\Mod\Product\Entity\PromoRedemption;
use Box\Mod\Product\Service as ProductService;
use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use function Tests\Helpers\container;
@@ -57,6 +60,21 @@ function createPromoEntity(int $id): Promo
return $promo;
}
/** @return array{SymfonyEventDispatcher, ArrayObject} */
function cartAddItemEventDispatcher(): array
{
$events = new ArrayObject();
$dispatcher = new SymfonyEventDispatcher();
foreach ([BeforeProductAddedToCartEvent::class, AfterProductAddedToCartEvent::class] as $eventClass) {
$dispatcher->addListener($eventClass, static function (object $event) use ($events): void {
$events->append($event);
});
}
return [$dispatcher, $events];
}
test('gets dependency injection container', function (): void {
$service = new Service();
@@ -715,8 +733,19 @@ test('checkoutCart returns array with expected keys', function (): void {
$serviceMock->shouldReceive('isPromoAvailableForClientGroup')->atLeast()->once()->andReturn(true);
$serviceMock->shouldReceive('getCartProducts')->once()->with($cart)->andReturn([]);
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$events = new ArrayObject();
$dispatcher = new readonly class($events) {
public function __construct(private ArrayObject $events)
{
}
public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event
{
$this->events->append($event);
return $event;
}
};
$invoice = createEntity(Invoice::class);
@@ -731,9 +760,9 @@ test('checkoutCart returns array with expected keys', function (): void {
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [])->andReturn([]);
$di = container();
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $dispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$di['request'] = new Request();
$di['request'] = Request::create('http://localhost', server: ['REMOTE_ADDR' => '192.0.2.1']);
$di['mod_service'] = $di->protect(fn () => $productService);
$serviceMock->setDi($di);
@@ -744,6 +773,10 @@ test('checkoutCart returns array with expected keys', function (): void {
expect($result)->toHaveKey('invoice_hash');
expect($result)->toHaveKey('order_id');
expect($result)->toHaveKey('orders');
expect($events->getArrayCopy())->toEqual([
new Box\Mod\Cart\Event\BeforeClientCheckoutEvent((int) $cart->getId(), (int) $client->getId(), '192.0.2.1'),
new Box\Mod\Order\Event\AfterClientOrderCreateEvent(99, (int) $client->getId(), '192.0.2.1'),
]);
});
test('checkoutCart throws exception when client is not able to use promo', function (): void {
@@ -1732,8 +1765,7 @@ test('addItem throws exception when recurring payment period param missing', fun
$data = [];
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$serviceHostingServiceMock = Mockery::mock(Box\Mod\Servicehosting\Service::class)->shouldIgnoreMissing();
$serviceMock = Mockery::mock(Service::class)->makePartial();
@@ -1741,7 +1773,7 @@ test('addItem throws exception when recurring payment period param missing', fun
$productService = new ProductService();
$di = container();
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) {
if ($name === 'Product') {
return $productService;
@@ -1766,8 +1798,7 @@ test('addItem throws exception when recurring payment period is not enabled', fu
$data = ['period' => '1W'];
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$serviceHostingServiceMock = Mockery::mock(Box\Mod\Servicehosting\Service::class)->shouldIgnoreMissing();
@@ -1777,7 +1808,7 @@ test('addItem throws exception when recurring payment period is not enabled', fu
$productService = new ProductService();
$di = container();
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) {
if ($name === 'Product') {
return $productService;
@@ -1803,8 +1834,7 @@ test('addItem throws exception when out of stock', function (): void {
$data = [];
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$serviceHostingServiceMock = Mockery::mock(Box\Mod\Servicehosting\Service::class)->shouldIgnoreMissing();
@@ -1821,7 +1851,7 @@ test('addItem throws exception when out of stock', function (): void {
$productService = new ProductService();
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) {
if ($name === 'Product') {
return $productService;
@@ -1850,8 +1880,7 @@ test('addItem rejects cumulative stock overflow', function (): void {
$existingCartProduct->product_id = 7;
$existingCartProduct->config = json_encode(['quantity' => 1]);
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$serviceHostingServiceMock = Mockery::mock(Box\Mod\Servicehosting\Service::class)->shouldIgnoreMissing();
$productServiceMock = Mockery::mock(ProductService::class)->shouldIgnoreMissing();
@@ -1868,7 +1897,7 @@ test('addItem rejects cumulative stock overflow', function (): void {
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productServiceMock) {
if ($name === 'Product') {
return $productServiceMock;
@@ -1900,8 +1929,7 @@ test('addItem rejects duplicate domain register', function (): void {
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(CartProduct::class)->andReturn($cartProductRepo);
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldReceive('isRecurrentPricing')->atLeast()->once()->andReturn(false);
@@ -1911,7 +1939,7 @@ test('addItem rejects duplicate domain register', function (): void {
$productService = new ProductService();
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) {
if ($name === 'Product') {
return $productService;
@@ -1943,8 +1971,7 @@ test('addItem rejects duplicate domain transfer', function (): void {
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(CartProduct::class)->andReturn($cartProductRepo);
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldReceive('isRecurrentPricing')->atLeast()->once()->andReturn(false);
@@ -1954,7 +1981,7 @@ test('addItem rejects duplicate domain transfer', function (): void {
$productService = new ProductService();
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) {
if ($name === 'Product') {
return $productService;
@@ -1988,8 +2015,7 @@ test('addItem rejects duplicate domain nested', function (): void {
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(CartProduct::class)->andReturn($cartProductRepo);
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldReceive('isRecurrentPricing')->atLeast()->once()->andReturn(false);
@@ -1999,7 +2025,7 @@ test('addItem rejects duplicate domain nested', function (): void {
$productService = new ProductService();
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) {
if ($name === 'Product') {
return $productService;
@@ -2024,8 +2050,7 @@ test('addItem for hosting type returns true', function (): void {
$data = [];
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$productDomainModel = createProductEntity(type: 'domain');
$domainProduct = ['config' => [], 'product' => $productDomainModel];
@@ -2048,7 +2073,7 @@ test('addItem for hosting type returns true', function (): void {
$productService = new ProductService();
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) {
if ($name === 'Product') {
return $productService;
@@ -2073,8 +2098,7 @@ test('addItem for license type returns true', function (): void {
$data = [];
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$serviceLicenseServiceMock = Mockery::mock(Box\Mod\Servicelicense\Service::class)->shouldIgnoreMissing();
$serviceLicenseServiceMock->shouldReceive('attachOrderConfig')->atLeast()->once()->andReturn([]);
@@ -2094,7 +2118,7 @@ test('addItem for license type returns true', function (): void {
$productService = new ProductService();
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(function ($name) use ($serviceLicenseServiceMock, $productService) {
if ($name === 'Product') {
return $productService;
@@ -2119,8 +2143,7 @@ test('addItem for custom type returns true', function (): void {
$data = [];
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$serviceCustomServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class);
$serviceCustomServiceMock->shouldReceive('validateCustomForm')->atLeast()->once();
@@ -2140,7 +2163,7 @@ test('addItem for custom type returns true', function (): void {
$productService = new ProductService();
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(function ($name) use ($serviceCustomServiceMock, $productService) {
if ($name === 'Product') {
return $productService;
@@ -2415,8 +2438,7 @@ test('addItem strips client-injected hosting_plan_id', function (): void {
'multiple' => 1,
];
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$productDomainModel = createProductEntity(type: 'domain');
$domainProduct = ['config' => [], 'product' => $productDomainModel];
@@ -2452,7 +2474,7 @@ test('addItem strips client-injected hosting_plan_id', function (): void {
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(function ($name) use ($serviceHostingServiceMock, $productService) {
if ($name === 'Product') {
return $productService;
@@ -2510,8 +2532,7 @@ test('addItem stamps one shared cart family across parent and addons', function
$parentModel = createProductEntity(id: 5, type: 'custom');
$addonModel = createProductEntity(id: 9, type: 'custom');
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
[$eventDispatcher, $events] = cartAddItemEventDispatcher();
$cartProductRepo = Mockery::mock(CartProductRepository::class);
$cartProductRepo->shouldReceive('findByCartId')->atLeast()->once()->andReturn([]);
@@ -2542,12 +2563,24 @@ test('addItem stamps one shared cart family across parent and addons', function
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(fn ($name) => $name === 'Product' ? $productServiceMock : new stdClass());
$di['logger'] = new FOSSBilling\Logger();
$serviceMock->setDi($di);
expect($serviceMock->addItem($cartModel, $parentModel, ['addons' => ['9' => ['selected' => true]]]))->toBeTrue();
$addData = [
'addons' => ['9' => ['selected' => true]],
'domain' => [
'action' => 'transfer',
'transfer_sld' => 'example',
'transfer_tld' => '.com',
'transfer_code' => 'secret-epp-code',
],
'hosting_password' => 'secret-hosting-password',
'custom_field' => 'public-value',
];
expect($serviceMock->addItem($cartModel, $parentModel, $addData))->toBeTrue();
expect($storedConfigs)->toHaveCount(2);
$firstFamily = $storedConfigs[0][Service::CART_FAMILY_KEY] ?? null;
@@ -2562,6 +2595,24 @@ test('addItem stamps one shared cart family across parent and addons', function
expect($secondFamily)->toBeString();
expect($secondFamily)->not->toBe('');
expect($secondFamily)->not->toBe($firstFamily);
expect(array_map(static fn (object $event): string => $event::class, $events->getArrayCopy()))
->toBe([
BeforeProductAddedToCartEvent::class,
AfterProductAddedToCartEvent::class,
BeforeProductAddedToCartEvent::class,
AfterProductAddedToCartEvent::class,
]);
expect($events[0]->cartId)->toBe(1);
expect($events[0]->productId)->toBe(5);
expect(array_keys(get_object_vars($events[0])))->toBe(['cartId', 'productId']);
expect($events[1]->cartId)->toBe(1);
expect($events[1]->productId)->toBe(5);
expect(array_keys(get_object_vars($events[1])))->toBe(['cartId', 'productId']);
expect($events[2]->cartId)->toBe(1);
expect($events[2]->productId)->toBe(5);
expect($events[3]->cartId)->toBe(1);
expect($events[3]->productId)->toBe(5);
});
test('getEffectiveCartPromos returns the manual promo when a code is set', function (): void {
+19 -16
View File
@@ -12,6 +12,8 @@ declare(strict_types=1);
use Box\Mod\Cart\Entity\Cart;
use Box\Mod\Cart\Entity\CartProduct;
use Box\Mod\Cart\Event\AfterStaffOrderCreateEvent;
use Box\Mod\Cart\Event\BeforeStaffCheckoutEvent;
use Box\Mod\Cart\Repository\CartProductRepository;
use Box\Mod\Cart\Repository\CartRepository;
use Box\Mod\Cart\Service;
@@ -23,6 +25,7 @@ use Box\Mod\Invoice\Entity\Invoice;
use Box\Mod\Order\Entity\Order;
use Box\Mod\Product\Entity\Product;
use Box\Mod\Product\Service as ProductService;
use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher;
use function Tests\Helpers\container;
use function Tests\Helpers\createEntity;
@@ -112,8 +115,7 @@ test('addItem strips a forged price override from client input', function (): vo
$productModel = createProductEntity(id: 5, type: 'custom');
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$cartProductRepo = Mockery::mock(CartProductRepository::class);
$cartProductRepo->shouldReceive('findByCartId')->atLeast()->once()->andReturn([]);
@@ -140,7 +142,7 @@ test('addItem strips a forged price override from client input', function (): vo
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(fn ($name) => $name === 'Product' ? $productServiceMock : new stdClass());
$di['logger'] = new FOSSBilling\Logger();
$serviceMock->setDi($di);
@@ -158,8 +160,7 @@ test('addItem stamps a staff price override on the main row only', function ():
$parentModel = createProductEntity(id: 5, type: 'custom');
$addonModel = createProductEntity(id: 9, type: 'custom');
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = new SymfonyEventDispatcher();
$cartProductRepo = Mockery::mock(CartProductRepository::class);
$cartProductRepo->shouldReceive('findByCartId')->atLeast()->once()->andReturn([]);
@@ -188,7 +189,7 @@ test('addItem stamps a staff price override on the main row only', function ():
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(fn ($name) => $name === 'Product' ? $productServiceMock : new stdClass());
$di['logger'] = new FOSSBilling\Logger();
$serviceMock->setDi($di);
@@ -258,12 +259,12 @@ test('checkoutStaffBasket checks out and destroys the basket', function (): void
$productServiceMock = Mockery::mock(ProductService::class);
$events = [];
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventMock->shouldReceive('fire')->andReturnUsing(function (array $event) use (&$events): bool {
$events[] = $event;
return true;
});
$eventDispatcher = new SymfonyEventDispatcher();
foreach ([BeforeStaffCheckoutEvent::class, AfterStaffOrderCreateEvent::class] as $eventClass) {
$eventDispatcher->addListener($eventClass, static function (object $event) use (&$events): void {
$events[] = $event;
});
}
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldReceive('createOrdersFromCart')
@@ -273,7 +274,7 @@ test('checkoutStaffBasket checks out and destroys the basket', function (): void
$serviceMock->shouldReceive('rm')->once()->with($basket)->andReturn(true);
$di = container();
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['logger'] = new FOSSBilling\Logger();
$di['mod_service'] = $di->protect(fn ($name) => $name === 'Product' ? $productServiceMock : new stdClass());
$serviceMock->setDi($di);
@@ -285,7 +286,9 @@ test('checkoutStaffBasket checks out and destroys the basket', function (): void
expect($result['invoice_hash'])->toBe('basket-hash');
expect($result['order_id'])->toBe(21);
expect($result['orders'])->toBe([21, 22]);
expect(array_column($events, 'event'))->toBe(['onBeforeStaffCheckout', 'onAfterStaffOrderCreate']);
expect(array_map(static fn (object $event): string => $event::class, $events))->toBe([BeforeStaffCheckoutEvent::class, AfterStaffOrderCreateEvent::class]);
expect($events[0]->cartId)->toBe(3);
expect($events[1]->orderId)->toBe(21);
});
test('checkoutStaffBasket marks the invoice paid when requested', function (): void {
@@ -313,14 +316,14 @@ test('checkoutStaffBasket marks the invoice paid when requested', function (): v
->with($invoice, ['gateway_id' => 5, 'transactionId' => 'txn-1'])
->andReturn(true);
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
$eventDispatcher = new SymfonyEventDispatcher();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldReceive('createOrdersFromCart')->once()->andReturn([$masterOrder, $invoice, [21]]);
$serviceMock->shouldReceive('rm')->once()->andReturn(true);
$di = container();
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['logger'] = new FOSSBilling\Logger();
$di['mod_service'] = $di->protect(fn ($name) => match ($name) {
'Product' => $productServiceMock,
+16 -12
View File
@@ -18,6 +18,12 @@ namespace Box\Mod\Client\Api;
use Box\Mod\Client\Entity\Client;
use Box\Mod\Client\Entity\ClientBalance;
use Box\Mod\Client\Entity\ClientGroup;
use Box\Mod\Client\Event\AfterAdminClientDeleteEvent;
use Box\Mod\Client\Event\AfterAdminClientPasswordChangeEvent;
use Box\Mod\Client\Event\AfterAdminClientUpdateEvent;
use Box\Mod\Client\Event\BeforeAdminClientDeleteEvent;
use Box\Mod\Client\Event\BeforeAdminClientPasswordChangeEvent;
use Box\Mod\Client\Event\BeforeAdminClientUpdateEvent;
use FOSSBilling\InformationException;
use FOSSBilling\PaginationOptions;
use FOSSBilling\Tools;
@@ -217,11 +223,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$validator->isPasswordStrong($data['password']);
}
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminClientCreate', 'params' => $data]);
$id = $service->adminCreateClient($data);
$this->getDi()['events_manager']->fire(['event' => 'onAfterAdminClientCreate', 'params' => $data]);
return $id;
return $service->adminCreateClient($data);
}
/**
@@ -234,12 +236,12 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$model = $this->getDi()['em']->getRepository(Client::class)->find($data['id']) ?? throw new InformationException('Client not found');
$clientId = $model->getId();
$clientId = (int) $model->getId();
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminClientDelete', 'params' => ['id' => $clientId]]);
$this->getDi()['event_dispatcher']->dispatch(new BeforeAdminClientDeleteEvent($clientId));
$this->getService()->remove($model);
$this->getDi()['events_manager']->fire(['event' => 'onAfterAdminClientDelete', 'params' => ['id' => $clientId]]);
$this->getDi()['event_dispatcher']->dispatch(new AfterAdminClientDeleteEvent($clientId));
$this->getDi()['logger']->info('Removed client #{client_id}', ['client_id' => $clientId]);
@@ -329,7 +331,9 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$client->setCurrency($currency);
}
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminClientUpdate', 'params' => $data]);
$eventInput = $data;
unset($eventInput['password'], $eventInput['password_confirm']);
$this->getDi()['event_dispatcher']->dispatch(new BeforeAdminClientUpdateEvent((int) $client->getId(), $eventInput));
// Special handling for the phone country codes
$phoneCountryCode = $data['phone_cc'] ?? $client->getPhoneCc();
@@ -460,7 +464,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$profileService->invalidateSessions('client', (int) $client->getId());
}
$this->getDi()['events_manager']->fire(['event' => 'onAfterAdminClientUpdate', 'params' => ['id' => $client->getId()]]);
$this->getDi()['event_dispatcher']->dispatch(new AfterAdminClientUpdateEvent((int) $client->getId()));
$this->getDi()['logger']->info('Updated client #{client_id} profile', ['client_id' => $client->getId()]);
@@ -481,7 +485,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$client = $this->getDi()['em']->getRepository(Client::class)->find($data['id']) ?? throw new InformationException('Client not found');
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminClientPasswordChange', 'params' => ['id' => $client->getId()]]);
$this->getDi()['event_dispatcher']->dispatch(new BeforeAdminClientPasswordChangeEvent((int) $client->getId()));
$client->setPass($this->getDi()['password']->hashIt($data['password']));
$this->getDi()['em']->persist($client);
@@ -490,7 +494,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$profileService = $this->getDi()['mod_service']('profile');
$profileService->invalidateSessions('client', (int) $data['id']);
$this->getDi()['events_manager']->fire(['event' => 'onAfterAdminClientPasswordChange', 'params' => ['id' => $client->getId()]]);
$this->getDi()['event_dispatcher']->dispatch(new AfterAdminClientPasswordChangeEvent((int) $client->getId()));
$this->getDi()['logger']->info('Changed client #{client_id} password', ['client_id' => $client->getId()]);
+14 -9
View File
@@ -17,6 +17,13 @@ namespace Box\Mod\Client\Api;
use Box\Mod\Client\Entity\Client;
use Box\Mod\Client\Entity\ClientPasswordReset;
use Box\Mod\Client\Event\AfterClientLoginEvent;
use Box\Mod\Client\Event\AfterClientPasswordResetEvent;
use Box\Mod\Client\Event\BeforeClientLoginEvent;
use Box\Mod\Client\Event\BeforeClientPasswordResetConfirmationEvent;
use Box\Mod\Client\Event\BeforeClientPasswordResetEvent;
use Box\Mod\Client\Event\BeforeClientPasswordResetRequestEvent;
use Box\Mod\Client\Event\ClientLoginFailedEvent;
use Box\Mod\Client\Service;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
@@ -233,20 +240,18 @@ class Guest extends \FOSSBilling\Api\AbstractApi
try {
$this->getDi()['tools']->validateAndSanitizeEmail($data['email'], true, false);
$event_params = $data;
$event_params['ip'] = $this->ip;
$this->getDi()['events_manager']->fire(['event' => 'onBeforeClientLogin', 'params' => $event_params]);
$this->getDi()['event_dispatcher']->dispatch(new BeforeClientLoginEvent($this->ip));
$service = $this->getService();
$client = $service->authorizeClient($data['email'], $data['password']);
if (!$client instanceof Client) {
$this->getDi()['events_manager']->fire(['event' => 'onEventClientLoginFailed', 'params' => $event_params]);
$this->getDi()['event_dispatcher']->dispatch(new ClientLoginFailedEvent($this->ip));
throw new \FOSSBilling\InformationException('Please check your login details.', [], 401);
}
$this->getDi()['events_manager']->fire(['event' => 'onAfterClientLogin', 'params' => ['id' => $client->getId(), 'ip' => $this->ip]]);
$this->getDi()['event_dispatcher']->dispatch(new AfterClientLoginEvent((int) $client->getId(), $this->ip));
$oldSession = $this->getDi()['session']->getId();
$this->getDi()['session']->regenerateId();
@@ -279,7 +284,7 @@ class Guest extends \FOSSBilling\Api\AbstractApi
$startedAt = microtime(true);
try {
$this->getDi()['events_manager']->fire(['event' => 'onBeforePasswordResetClient']);
$this->getDi()['event_dispatcher']->dispatch(new BeforeClientPasswordResetEvent($this->getIp()));
$service = $this->getDi()['mod_service']('client');
// Sanitize email
@@ -301,7 +306,7 @@ class Guest extends \FOSSBilling\Api\AbstractApi
$this->checkCaptchaIfEnabled($data);
$this->getDi()['events_manager']->fire(['event' => 'onBeforeGuestPasswordResetRequest', 'params' => $data]);
$this->getDi()['event_dispatcher']->dispatch(new BeforeClientPasswordResetRequestEvent($this->getIp()));
$em = $this->getDi()['em'];
$client = $em->getRepository(Client::class)->findOneByEmailAndActive($data['email']);
@@ -336,7 +341,7 @@ class Guest extends \FOSSBilling\Api\AbstractApi
try {
$this->getDi()['rate_limiter']->consumeOrThrow('client_password_reset_confirm_post_ip', (string) $this->getIp());
$this->getDi()['events_manager']->fire(['event' => 'onBeforeClientProfilePasswordReset', 'params' => $data['hash']]);
$this->getDi()['event_dispatcher']->dispatch(new BeforeClientPasswordResetConfirmationEvent($this->getIp()));
$this->getDi()['validator']->passwordsMatch($data);
$this->getDi()['validator']->isPasswordStrong($data['password']);
@@ -382,7 +387,7 @@ class Guest extends \FOSSBilling\Api\AbstractApi
$email['code'] = 'mod_client_password_reset_information';
$emailService = $this->getDi()['mod_service']('email');
$emailService->sendTemplate($email);
$this->getDi()['events_manager']->fire(['event' => 'onAfterClientProfilePasswordReset', 'params' => ['id' => $client->getId()]]);
$this->getDi()['event_dispatcher']->dispatch(new AfterClientPasswordResetEvent((int) $client->getId()));
return true;
} finally {
-1
View File
@@ -88,7 +88,6 @@ class Client implements \FOSSBilling\InjectionAwareInterface
}
$service = $this->di['mod_service']('client');
$this->di['events_manager']->fire(['event' => 'onBeforePasswordResetClient']);
$data = [
'hash' => $hash,
];
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an admin created a new client. */
final class AfterAdminClientCreateEvent extends Event
{
public function __construct(
public readonly int $clientId,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class AfterAdminClientDeleteEvent extends Event
{
public function __construct(
public readonly int $clientId,
) {
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class AfterAdminClientPasswordChangeEvent extends Event
{
public function __construct(public readonly int $clientId)
{
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class AfterAdminClientUpdateEvent extends Event
{
public function __construct(public readonly int $clientId)
{
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class AfterClientLoginEvent extends Event
{
public function __construct(
public readonly int $clientId,
public readonly ?string $ip,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class AfterClientPasswordResetEvent extends Event
{
public function __construct(
public readonly int $clientId,
) {
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
/** Dispatched after a guest signup has persisted its new client. */
final class AfterClientSignUpEvent extends Event
{
public function __construct(
public readonly int $clientId,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
/** Admin client creation input excluding the password fields. */
final class BeforeAdminClientCreateEvent extends Event
{
/** @param array<string, mixed> $input */
public function __construct(
public readonly array $input,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class BeforeAdminClientDeleteEvent extends Event
{
public function __construct(
public readonly int $clientId,
) {
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class BeforeAdminClientPasswordChangeEvent extends Event
{
public function __construct(public readonly int $clientId)
{
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class BeforeAdminClientUpdateEvent extends Event
{
/** @param array<string, mixed> $input Validated input, excluding credentials. */
public function __construct(
public readonly int $clientId,
public readonly array $input,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class BeforeClientLoginEvent extends Event
{
public function __construct(
public readonly ?string $ip,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class BeforeClientPasswordResetConfirmationEvent extends Event
{
public function __construct(
public readonly ?string $ip,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class BeforeClientPasswordResetEvent extends Event
{
public function __construct(
public readonly ?string $ip,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class BeforeClientPasswordResetRequestEvent extends Event
{
public function __construct(
public readonly ?string $ip,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
/** Signup input excluding the password fields, with the request IP included. */
final class BeforeClientSignUpEvent extends Event
{
/** @param array<string, mixed> $input */
public function __construct(
public readonly array $input,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Client\Event;
use FOSSBilling\Events\Event;
final class ClientLoginFailedEvent extends Event
{
public function __construct(
public readonly ?string $ip,
) {
}
}
+24 -22
View File
@@ -15,16 +15,24 @@ use Box\Mod\Client\Entity\Client;
use Box\Mod\Client\Entity\ClientBalance;
use Box\Mod\Client\Entity\ClientGroup;
use Box\Mod\Client\Entity\ClientPasswordReset;
use Box\Mod\Client\Event\AfterAdminClientCreateEvent;
use Box\Mod\Client\Event\AfterClientSignUpEvent;
use Box\Mod\Client\Event\BeforeAdminClientCreateEvent;
use Box\Mod\Client\Event\BeforeClientPasswordResetEvent;
use Box\Mod\Client\Event\BeforeClientSignUpEvent;
use Box\Mod\Client\Repository\ClientBalanceRepository;
use Box\Mod\Client\Repository\ClientGroupRepository;
use Box\Mod\Client\Repository\ClientPasswordResetRepository;
use Box\Mod\Client\Repository\ClientRepository;
use Box\Mod\Cron\Event\BeforeAdminCronRunEvent;
use Box\Mod\Staff\Entity\Admin;
use FOSSBilling\i18n;
use FOSSBilling\InformationException;
use FOSSBilling\InjectionAwareInterface;
use FOSSBilling\SortOptions;
use FOSSBilling\Tools;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Intl\Countries;
use Symfony\Component\Intl\Locales;
@@ -183,30 +191,28 @@ class Service implements InjectionAwareInterface
return $this->di['tools']->url('/client/confirm-email/' . $hash);
}
public static function onAfterClientSignUp(\Box_Event $event): bool
#[AsEventListener]
public function sendSignupEmail(AfterClientSignUpEvent $event): void
{
$di = $event->getDi();
$params = $event->getParameters();
$di = $this->di ?? throw new \LogicException('Client service must be initialized before handling events.');
$config = $di['mod_config']('client');
$emailService = $di['mod_service']('email');
try {
$email = [];
$email['to_client'] = $params['id'];
$email['to_client'] = $event->clientId;
$email['code'] = 'mod_client_signup';
$email['require_email_confirmation'] = false;
if (isset($config['require_email_confirmation']) && $config['require_email_confirmation']) {
$clientService = $di['mod_service']('client');
$email['require_email_confirmation'] = true;
$email['email_confirmation_link'] = $clientService->generateEmailConfirmationLink($params['id']);
$email['email_confirmation_link'] = $clientService->generateEmailConfirmationLink($event->clientId);
}
$emailService->sendTemplate($email);
} catch (\Exception $exc) {
$di['logger']->withChannel('email')->error('Failed to send client signup email', ['exception' => $exc]);
}
return true;
}
public function getSearchQuery($data, $selectStmt = null): array
@@ -721,12 +727,12 @@ class Service implements InjectionAwareInterface
{
$eventParams = $data;
unset($eventParams['password'], $eventParams['password_confirm']);
$this->di['events_manager']->fire(['event' => 'onBeforeAdminCreateClient', 'params' => $eventParams]);
$this->di['event_dispatcher']->dispatch(new BeforeAdminClientCreateEvent($eventParams));
$client = $this->createClient($data);
if (Tools::normalizeBoolean($data['send_welcome_email'] ?? true, true)) {
$this->sendAdminCreatedWelcomeEmailForClient($client);
}
$this->di['events_manager']->fire(['event' => 'onAfterAdminCreateClient', 'params' => ['id' => $client->getId()]]);
$this->di['event_dispatcher']->dispatch(new AfterAdminClientCreateEvent((int) $client->getId()));
$this->di['logger']->info('Created new client #{client_id}', ['client_id' => $client->getId()]);
return (int) $client->getId();
@@ -737,7 +743,7 @@ class Service implements InjectionAwareInterface
$event_params = $data;
$event_params['ip'] = $this->di['request']->getClientIp();
unset($event_params['password'], $event_params['password_confirm']);
$this->di['events_manager']->fire(['event' => 'onBeforeClientSignUp', 'params' => $event_params]);
$this->di['event_dispatcher']->dispatch(new BeforeClientSignUpEvent($event_params));
$allowedFields = [
'email', 'first_name', 'last_name', 'password',
@@ -763,14 +769,7 @@ class Service implements InjectionAwareInterface
$client = $this->createClient($safeData);
$event_params = [
'id' => $client->getId(),
'email' => $client->getEmail(),
'first_name' => $client->getFirstName(),
'last_name' => $client->getLastName(),
'ip' => $safeData['ip'],
];
$this->di['events_manager']->fire(['event' => 'onAfterClientSignUp', 'params' => $event_params]);
$this->di['event_dispatcher']->dispatch(new AfterClientSignUpEvent((int) $client->getId()));
$this->di['logger']->info('Client #{client_id} signed up', ['client_id' => $client->getId()]);
return $client;
@@ -1019,7 +1018,9 @@ class Service implements InjectionAwareInterface
$required = [
'hash' => 'Hash required',
];
$this->di['events_manager']->fire(['event' => 'onBeforePasswordResetClient']);
$request = $this->di['request'] ?? null;
$ip = $request instanceof Request ? $request->getClientIp() : null;
$this->di['event_dispatcher']->dispatch(new BeforeClientPasswordResetEvent($ip));
$this->di['validator']->checkRequiredParamsForArray($required, $data);
$reset = $this->clientPasswordResetRepository->findOneByHash($data['hash']);
@@ -1044,13 +1045,14 @@ class Service implements InjectionAwareInterface
*
* @return void
*/
public static function onBeforeAdminCronRun(\Box_Event $event): void
#[AsEventListener]
public function removeExpiredPasswordResetRequests(BeforeAdminCronRunEvent $event): void
{
$di = $event->getDi();
$di = $this->di ?? throw new \LogicException('The Client service dependency injection container has not been set.');
try {
$cutoff = new \DateTime('-900 seconds');
$di['em']->getRepository(ClientPasswordReset::class)
$this->clientPasswordResetRepository
->createQueryBuilder('r')
->delete()
->where('r.createdAt < :cutoff')
+54 -18
View File
@@ -142,14 +142,10 @@ test('create returns int', function (): void {
$serviceMock->shouldReceive('emailAlreadyRegistered')->atLeast()->once()->andReturn(false);
$serviceMock->shouldReceive('adminCreateClient')->atLeast()->once()->andReturn(1);
$eventMock = Mockery::mock('\Box_EventManager');
$eventMock->shouldReceive('fire')->atLeast()->once();
$toolsMock = Mockery::mock(FOSSBilling\Tools::class);
$toolsMock->shouldReceive('validateAndSanitizeEmail')->atLeast()->once();
$di = container();
$di['events_manager'] = $eventMock;
$di['tools'] = $toolsMock;
$adminClient->setDi($di);
@@ -186,14 +182,27 @@ test('delete returns true', function (): void {
$adminClient = apiEndpoint(new Box\Mod\Client\Api\Admin());
$data = ['id' => 1];
$eventMock = Mockery::mock('\Box_EventManager');
$eventMock->shouldReceive('fire')->atLeast()->once();
$calls = new ArrayObject();
$dispatcher = new readonly class($calls) {
public function __construct(private ArrayObject $calls)
{
}
public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event
{
$this->calls->append($event);
return $event;
}
};
$serviceMock = Mockery::mock(Box\Mod\Client\Service::class)->makePartial();
$serviceMock->shouldReceive('remove')->atLeast()->once();
$serviceMock->shouldReceive('remove')->once()->andReturnUsing(static function () use ($calls): void {
$calls->append('remove');
});
$di = container();
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $dispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$validatorStub = $this->createStub(FOSSBilling\Validate::class);
$di['validator'] = $validatorStub;
@@ -201,7 +210,13 @@ test('delete returns true', function (): void {
$adminClient->setDi($di);
$adminClient->setService($serviceMock);
$result = $adminClient->delete($data);
expect($result)->toBeTrue();
expect($calls->getArrayCopy())->toEqual([
new Box\Mod\Client\Event\BeforeAdminClientDeleteEvent(1),
'remove',
new Box\Mod\Client\Event\AfterAdminClientDeleteEvent(1),
]);
});
test('update returns true', function (): void {
@@ -249,21 +264,34 @@ test('update returns true', function (): void {
$serviceMock->shouldReceive('emailAlreadyRegistered')->atLeast()->once()->andReturn(false);
$serviceMock->shouldReceive('canChangeCurrency')->atLeast()->once()->andReturn(true);
$eventMock = Mockery::mock('\Box_EventManager');
$eventMock->shouldReceive('fire')->atLeast()->once();
$dispatcher = new class {
public array $events = [];
public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event
{
$this->events[] = $event;
return $event;
}
};
$toolsMock = Mockery::mock(FOSSBilling\Tools::class);
$toolsMock->shouldReceive('validateAndSanitizeEmail')->atLeast()->once();
$di = container();
$di['mod_service'] = $di->protect(moduleService(['client' => $serviceMock]));
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $dispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$di['tools'] = $toolsMock;
$adminClient->setDi($di);
$result = $adminClient->update($data);
expect($result)->toBeTrue();
expect($dispatcher->events)->toHaveCount(2);
expect($dispatcher->events[0])->toBeInstanceOf(Box\Mod\Client\Event\BeforeAdminClientUpdateEvent::class);
expect($dispatcher->events[0]->clientId)->toBe(1);
expect($dispatcher->events[0]->input)->not->toHaveKey('password');
expect($dispatcher->events[1])->toEqual(new Box\Mod\Client\Event\AfterAdminClientUpdateEvent(1));
});
test('update validates and assigns client_group_id through the group repository', function (): void {
@@ -376,12 +404,8 @@ test('update throws exception when email is already registered', function (): vo
$serviceMock = Mockery::mock(Box\Mod\Client\Service::class);
$serviceMock->shouldReceive('emailAlreadyRegistered')->atLeast()->once()->andReturn(true);
$eventMock = Mockery::mock('\Box_EventManager');
$eventMock->shouldReceive('fire');
$di = container();
$di['mod_service'] = $di->protect(moduleService(['client' => $serviceMock]));
$di['events_manager'] = $eventMock;
$di['logger'] = new Tests\Helpers\TestLogger();
$di['validator'] = new FOSSBilling\Validate();
@@ -418,8 +442,16 @@ test('changePassword returns true', function (): void {
'password_confirm' => 'strongPass',
];
$eventMock = Mockery::mock('\Box_EventManager');
$eventMock->shouldReceive('fire')->atLeast()->once();
$dispatcher = new class {
public array $events = [];
public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event
{
$this->events[] = $event;
return $event;
}
};
$passwordMock = Mockery::mock(FOSSBilling\PasswordManager::class);
$passwordMock->shouldReceive('hashIt')->atLeast()->once()->with($data['password']);
@@ -428,7 +460,7 @@ test('changePassword returns true', function (): void {
$profileService->shouldReceive('invalidateSessions')->atLeast()->once();
$di = container();
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $dispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$di['password'] = $passwordMock;
$validatorStub = $this->createStub(FOSSBilling\Validate::class);
@@ -439,6 +471,10 @@ test('changePassword returns true', function (): void {
$result = $adminClient->change_password($data);
expect($result)->toBeTrue();
expect($dispatcher->events)->toEqual([
new Box\Mod\Client\Event\BeforeAdminClientPasswordChangeEvent(1),
new Box\Mod\Client\Event\AfterAdminClientPasswordChangeEvent(1),
]);
});
test('changePassword throws exception when passwords do not match', function (): void {
+79 -17
View File
@@ -14,6 +14,21 @@ use function Tests\Helpers\container;
use function Tests\Helpers\createEntity;
use function Tests\Helpers\moduleService;
function clientGuestTestEventDispatcher(): object
{
return new class {
/** @var list<FOSSBilling\Events\Event> */
public array $dispatched = [];
public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event
{
$this->dispatched[] = $event;
return $event;
}
};
}
function guestClientDuplicateKeyViolation(): Doctrine\DBAL\Exception\UniqueConstraintViolationException
{
$driverException = new class extends Exception implements Doctrine\DBAL\Driver\Exception {
@@ -123,6 +138,7 @@ test('create returns true without creating a duplicate account or disclosing tha
$toolsMock = Mockery::mock(FOSSBilling\Tools::class);
$toolsMock->shouldReceive('validateAndSanitizeEmail')->atLeast()->once()->andReturn($data['email']);
$di['tools'] = $toolsMock;
$di['event_dispatcher'] = clientGuestTestEventDispatcher();
$guestClient->setDi($di);
$guestClient->setService($serviceMock);
@@ -176,6 +192,7 @@ test('create returns true without creating an account when the per-email signup
$di['mod_config'] = $di->protect(fn ($name): array => $configArr);
$di['validator'] = $validatorMock;
$di['rate_limiter'] = $rateLimiterMock;
$di['event_dispatcher'] = clientGuestTestEventDispatcher();
$toolsMock = Mockery::mock(FOSSBilling\Tools::class);
$toolsMock->shouldReceive('validateAndSanitizeEmail')->atLeast()->once()->andReturn($data['email']);
@@ -282,6 +299,7 @@ test('create returns generic success when a concurrent signup wins the email rac
$di['validator'] = $validatorMock;
$di['rate_limiter'] = $rateLimiterMock;
$di['tools'] = $toolsMock;
$di['event_dispatcher'] = clientGuestTestEventDispatcher();
$guestClient->setDi($di);
$guestClient->setService($serviceMock);
@@ -395,8 +413,7 @@ test('login returns array', function (): void {
->atLeast()->once()
->andReturn([]);
$eventMock = Mockery::mock('\Box_EventManager');
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = clientGuestTestEventDispatcher();
$sessionMock = Mockery::mock(FOSSBilling\Session::class);
$sessionMock->shouldReceive('set')->atLeast()->once();
@@ -412,7 +429,7 @@ test('login returns array', function (): void {
$toolsStub->shouldReceive('validateAndSanitizeEmail')->atLeast()->once()->with($data['email'], true, false)->andReturn($data['email']);
$di = container();
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['session'] = $sessionMock;
$di['logger'] = new Tests\Helpers\TestLogger();
$di['tools'] = $toolsStub;
@@ -420,18 +437,46 @@ test('login returns array', function (): void {
$guestClient->setDi($di);
$guestClient->setService($serviceMock);
$guestClient->setIp('192.0.2.1');
$results = $guestClient->login($data);
expect($results)->toBeArray();
expect($eventDispatcher->dispatched)->toHaveCount(2);
expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Client\Event\BeforeClientLoginEvent('192.0.2.1'));
expect($eventDispatcher->dispatched[1])->toEqual(new Box\Mod\Client\Event\AfterClientLoginEvent((int) $model->getId(), '192.0.2.1'));
expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.1']);
});
test('failed client login dispatches only IP metadata', function (): void {
$guestClient = apiEndpoint(new Box\Mod\Client\Api\Guest());
$service = Mockery::mock(Box\Mod\Client\Service::class);
$service->shouldReceive('authorizeClient')->once()->with('test@example.com', 'private-password')->andReturn(null);
$tools = Mockery::mock(FOSSBilling\Tools::class);
$tools->shouldReceive('validateAndSanitizeEmail')->once()->with('test@example.com', true, false)->andReturn('test@example.com');
$eventDispatcher = clientGuestTestEventDispatcher();
$di = container();
$di['event_dispatcher'] = $eventDispatcher;
$di['tools'] = $tools;
$guestClient->setDi($di);
$guestClient->setService($service);
$guestClient->setIp('192.0.2.2');
expect(fn () => $guestClient->login(['email' => 'test@example.com', 'password' => 'private-password']))
->toThrow(FOSSBilling\InformationException::class, 'Please check your login details.');
expect($eventDispatcher->dispatched)->toHaveCount(2);
expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Client\Event\BeforeClientLoginEvent('192.0.2.2'));
expect($eventDispatcher->dispatched[1])->toEqual(new Box\Mod\Client\Event\ClientLoginFailedEvent('192.0.2.2'));
expect(get_object_vars($eventDispatcher->dispatched[1]))->toBe(['ip' => '192.0.2.2']);
});
test('resetPassword returns true with new flow', function (): void {
$guestClient = apiEndpoint(new Box\Mod\Client\Api\Guest());
$data['email'] = 'John@exmaple.com';
$eventMock = Mockery::mock('\Box_EventManager');
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = clientGuestTestEventDispatcher();
$modelClient = createEntity(Box\Mod\Client\Entity\Client::class, ['id' => 1, 'status' => Box\Mod\Client\Entity\Client::ACTIVE]);
@@ -452,23 +497,27 @@ test('resetPassword returns true with new flow', function (): void {
$di = container();
$di['em'] = $em;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['mod_service'] = $di->protect(moduleService(['client' => $serviceMock]));
$di['logger'] = new Tests\Helpers\TestLogger();
$di['tools'] = $toolsMock;
$guestClient->setDi($di);
$guestClient->setIp('192.0.2.3');
$result = $guestClient->reset_password($data);
expect($result)->toBeTrue();
expect($eventDispatcher->dispatched)->toHaveCount(2);
expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Client\Event\BeforeClientPasswordResetEvent('192.0.2.3'));
expect($eventDispatcher->dispatched[1])->toEqual(new Box\Mod\Client\Event\BeforeClientPasswordResetRequestEvent('192.0.2.3'));
expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.3']);
});
test('resetPassword returns true when email not found', function (): void {
$guestClient = apiEndpoint(new Box\Mod\Client\Api\Guest());
$data['email'] = 'joghn@example.eu';
$eventMock = Mockery::mock('\Box_EventManager');
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = clientGuestTestEventDispatcher();
$clientRepository = Mockery::mock(Box\Mod\Client\Repository\ClientRepository::class);
$clientRepository->shouldReceive('findOneByEmailAndActive')->atLeast()->once()->andReturn(null);
@@ -481,7 +530,7 @@ test('resetPassword returns true when email not found', function (): void {
$di = container();
$di['em'] = $em;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$toolsMock = Mockery::mock(FOSSBilling\Tools::class);
@@ -489,9 +538,13 @@ test('resetPassword returns true when email not found', function (): void {
$di['tools'] = $toolsMock;
$guestClient->setDi($di);
$guestClient->setIp('192.0.2.4');
$result = $guestClient->reset_password($data);
expect($result)->toBeTrue();
expect($eventDispatcher->dispatched)->toHaveCount(2);
expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.4']);
expect(get_object_vars($eventDispatcher->dispatched[1]))->toBe(['ip' => '192.0.2.4']);
});
test('updatePassword returns true', function (): void {
@@ -524,8 +577,7 @@ test('updatePassword returns true', function (): void {
default => Mockery::mock()->shouldIgnoreMissing(),
});
$eventMock = Mockery::mock('\Box_EventManager');
$eventMock->shouldReceive('fire')->times(2);
$eventDispatcher = clientGuestTestEventDispatcher();
$passwordMock = Mockery::mock(FOSSBilling\PasswordManager::class);
$passwordMock->shouldReceive('hashIt')->atLeast()->once();
@@ -538,15 +590,20 @@ test('updatePassword returns true', function (): void {
$di = container();
$di['em'] = $em;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$di['password'] = $passwordMock;
$di['mod_service'] = $di->protect(moduleService(['email' => $emailServiceMock, 'profile' => $profileServiceMock]));
$guestClient->setDi($di);
$guestClient->setIp('192.0.2.5');
$result = $guestClient->update_password($data);
expect($result)->toBeTrue();
expect($eventDispatcher->dispatched)->toHaveCount(2);
expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Client\Event\BeforeClientPasswordResetConfirmationEvent('192.0.2.5'));
expect($eventDispatcher->dispatched[1])->toEqual(new Box\Mod\Client\Event\AfterClientPasswordResetEvent(1));
expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.5']);
});
test('updatePassword throws exception when reset not found', function (): void {
@@ -557,17 +614,22 @@ test('updatePassword throws exception when reset not found', function (): void {
'password_confirm' => 'NewPassword1',
];
$eventMock = Mockery::mock('\Box_EventManager');
$eventMock->shouldReceive('fire')->atLeast()->once();
$eventDispatcher = clientGuestTestEventDispatcher();
$di = container();
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$guestClient->setDi($di);
$guestClient->setIp('192.0.2.6');
$guestClient->update_password($data);
})->throws(FOSSBilling\Exception::class, 'The link has expired or you have already reset your password.');
expect(fn () => $guestClient->update_password($data))
->toThrow(FOSSBilling\Exception::class, 'The link has expired or you have already reset your password.');
expect($eventDispatcher->dispatched)->toHaveCount(1);
expect($eventDispatcher->dispatched[0])->toEqual(new Box\Mod\Client\Event\BeforeClientPasswordResetConfirmationEvent('192.0.2.6'));
expect(get_object_vars($eventDispatcher->dispatched[0]))->toBe(['ip' => '192.0.2.6']);
});
test('required returns array', function (): void {
$guestClient = apiEndpoint(new Box\Mod\Client\Api\Guest());
+173 -76
View File
@@ -10,6 +10,13 @@
declare(strict_types=1);
use Box\Mod\Client\Event\AfterAdminClientCreateEvent;
use Box\Mod\Client\Event\AfterClientSignUpEvent;
use Box\Mod\Client\Event\BeforeAdminClientCreateEvent;
use Box\Mod\Client\Event\BeforeClientPasswordResetEvent;
use Box\Mod\Client\Event\BeforeClientSignUpEvent;
use Box\Mod\Cron\Event\BeforeAdminCronRunEvent;
use function Tests\Helpers\container;
use function Tests\Helpers\createEntity;
use function Tests\Helpers\moduleService;
@@ -22,6 +29,58 @@ test('getDi returns dependency injection container', function (): void {
expect($getDi)->toEqual($di);
});
test('removes expired password reset requests on typed before cron event', function (): void {
$query = Mockery::mock(Doctrine\ORM\Query::class)->shouldIgnoreMissing();
$query->shouldReceive('execute')->once()->andReturn(1);
$queryBuilder = Mockery::mock(Doctrine\ORM\QueryBuilder::class)->shouldIgnoreMissing();
$queryBuilder->shouldReceive('delete')->once()->andReturnSelf();
$queryBuilder->shouldReceive('where')->once()->with('r.createdAt < :cutoff')->andReturnSelf();
$queryBuilder->shouldReceive('setParameter')->once()->with('cutoff', Mockery::type(DateTime::class))->andReturnSelf();
$queryBuilder->shouldReceive('getQuery')->once()->andReturn($query);
$di = container();
$passwordResetRepository = $di['em']->getRepository(Box\Mod\Client\Entity\ClientPasswordReset::class);
$passwordResetRepository->shouldReceive('createQueryBuilder')->once()->with('r')->andReturn($queryBuilder);
$service = new Box\Mod\Client\Service();
$service->setDi($di);
$service->removeExpiredPasswordResetRequests(new BeforeAdminCronRunEvent());
});
test('password_reset_valid dispatches a safe event without exposing the reset hash', function (): void {
$service = new Box\Mod\Client\Service();
$di = container();
$repository = $di['em']->getRepository(Box\Mod\Client\Entity\ClientPasswordReset::class);
$repository->shouldReceive('findOneByHash')->once()->with('private-reset-hash')->andReturn(null);
$events = [];
$eventDispatcher = new class($events) {
/** @param list<FOSSBilling\Events\Event> $events */
public function __construct(private array &$events)
{
}
public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event
{
$this->events[] = $event;
return $event;
}
};
$di['request'] = Symfony\Component\HttpFoundation\Request::create('http://localhost/', server: ['REMOTE_ADDR' => '192.0.2.7']);
$di['event_dispatcher'] = $eventDispatcher;
$service->setDi($di);
expect(fn () => $service->password_reset_valid(['hash' => 'private-reset-hash']))
->toThrow(FOSSBilling\InformationException::class, 'The link has expired or you have already reset your password.');
expect($events)->toHaveCount(1);
expect($events[0])->toEqual(new BeforeClientPasswordResetEvent('192.0.2.7'));
expect(get_object_vars($events[0]))->toBe(['ip' => '192.0.2.7']);
});
test('approveClientEmailByHash returns true', function (): void {
$service = new Box\Mod\Client\Service();
@@ -73,109 +132,62 @@ test('generateEmailConfirmationLink returns string', function (): void {
expect(str_contains((string) $result, '/client/confirm-email/'))->toBeTrue();
});
test('onAfterClientSignUp returns true', function (): void {
test('sendSignupEmail sends the client signup email', function (): void {
$service = new Box\Mod\Client\Service();
$eventParams = [
'password' => 'testPassword',
'id' => 1,
];
$eventMock = Mockery::mock('\Box_Event');
$eventMock->shouldReceive('getParameters')
->atLeast()->once()
->andReturn($eventParams);
$emailService = Mockery::mock(Box\Mod\Email\Service::class);
$emailService->shouldReceive('sendTemplate')
->atLeast()->once()
->andReturn(true);
->once()
->with([
'to_client' => 1,
'code' => 'mod_client_signup',
'require_email_confirmation' => false,
]);
$di = container();
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $emailService);
$di['mod_config'] = $di->protect(fn ($name): array => ['require_email_confirmation' => false]);
$eventMock->shouldReceive('getDi')
->atLeast()->once()
->andReturn($di);
$service->setDi($di);
$result = $service->onAfterClientSignUp($eventMock);
expect($result)->toBeTrue();
$service->sendSignupEmail(new AfterClientSignUpEvent(1));
});
test('onAfterClientSignUp with email confirmation required returns true', function (): void {
$service = new Box\Mod\Client\Service();
$eventMock = Mockery::mock('\Box_Event');
$eventParams = [
'password' => 'testPassword',
'id' => 1,
'require_email_confirmation' => true,
];
$eventMock->shouldReceive('getParameters')
->atLeast()->once()
->andReturn($eventParams);
test('sendSignupEmail includes a confirmation link when required', function (): void {
$service = Mockery::mock(Box\Mod\Client\Service::class)->makePartial();
$service->shouldReceive('generateEmailConfirmationLink')->once()->with(1)->andReturn('Link_string');
$emailService = Mockery::mock(Box\Mod\Email\Service::class);
$emailService->shouldReceive('sendTemplate')
->atLeast()->once()
->andReturn(true);
$clientServiceMock = Mockery::mock(Box\Mod\Client\Service::class)->makePartial();
$clientServiceMock->shouldReceive('generateEmailConfirmationLink')
->atLeast()->once()
->andReturn('Link_string');
->once()
->with([
'to_client' => 1,
'code' => 'mod_client_signup',
'require_email_confirmation' => true,
'email_confirmation_link' => 'Link_string',
]);
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailService, $clientServiceMock) {
if ($serviceName == 'email') {
return $emailService;
}
if ($serviceName == 'client') {
return $clientServiceMock;
}
});
$di['mod_service'] = $di->protect(fn ($serviceName) => $serviceName === 'email' ? $emailService : $service);
$di['mod_config'] = $di->protect(fn ($name): array => ['require_email_confirmation' => true]);
$eventMock->shouldReceive('getDi')
->atLeast()->once()
->andReturn($di);
$result = $service->onAfterClientSignUp($eventMock);
expect($result)->toBeTrue();
$service->setDi($di);
$service->sendSignupEmail(new AfterClientSignUpEvent(1));
});
test('onAfterClientSignUp handles exception gracefully', function (): void {
test('sendSignupEmail handles email exceptions gracefully', function (): void {
$service = new Box\Mod\Client\Service();
$eventParams = [
'password' => 'testPassword',
'id' => 1,
];
$eventMock = Mockery::mock('\Box_Event');
$eventMock->shouldReceive('getParameters')
->atLeast()->once()
->andReturn($eventParams);
$emailService = Mockery::mock(Box\Mod\Email\Service::class);
$emailService->shouldReceive('sendTemplate')
->atLeast()->once()
->once()
->andThrow(new Exception('exception created in unit test'));
$di = container();
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $emailService);
$di['mod_config'] = $di->protect(function ($name): void {
['require_email_confirmation' => false];
});
$eventMock->shouldReceive('getDi')
->atLeast()->once()
->andReturn($di);
$di['mod_config'] = $di->protect(fn ($name): array => ['require_email_confirmation' => false]);
$service->setDi($di);
$result = $service->onAfterClientSignUp($eventMock);
expect($result)->toBeTrue();
$service->sendSignupEmail(new AfterClientSignUpEvent(1));
});
dataset('searchQueryData', [
@@ -845,11 +857,23 @@ test('adminCreateClient returns int', function (): void {
'email' => 'test@unit.vm',
'first_name' => 'test',
'aid' => 'LEGACY-1001',
'password_confirm' => 'do-not-expose',
'send_welcome_email' => false,
];
$eventManagerMock = Mockery::mock('\Box_EventManager');
$eventManagerMock->shouldReceive('fire')
->twice();
$dispatched = [];
$eventDispatcher = new class($dispatched) {
public function __construct(private array &$dispatched)
{
}
public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event
{
$this->dispatched[] = $event;
return $event;
}
};
$passwordMock = Mockery::mock(FOSSBilling\PasswordManager::class);
$passwordMock->shouldReceive('hashIt')
@@ -862,15 +886,88 @@ test('adminCreateClient returns int', function (): void {
->andReturn([]);
$di = container();
$di['events_manager'] = $eventManagerMock;
$di['event_dispatcher'] = $eventDispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$di['mod'] = $di->protect(fn (): Mockery\MockInterface => $modMock);
$di['password'] = $passwordMock;
$entityManager = $di['em'];
$entityManager->shouldReceive('persist')->atLeast()->once()->andReturnUsing(static function (object $entity): void {
if ($entity instanceof Box\Mod\Client\Entity\Client) {
(new ReflectionProperty(Box\Mod\Client\Entity\Client::class, 'id'))->setValue($entity, 42);
}
});
$service->setDi($di);
$result = $service->adminCreateClient($data);
expect($result)->toBeInt();
expect($dispatched)->toHaveCount(2);
expect($dispatched[0])->toBeInstanceOf(BeforeAdminClientCreateEvent::class);
expect($dispatched[0]->input)->toBe([
'email' => 'test@unit.vm',
'first_name' => 'test',
'aid' => 'LEGACY-1001',
'send_welcome_email' => false,
]);
expect($dispatched[1])->toEqual(new AfterAdminClientCreateEvent(42));
});
test('guestCreateClient dispatches sanitized signup input and the persisted client ID', function (): void {
$service = new Box\Mod\Client\Service();
$data = [
'email' => 'test@unit.vm',
'first_name' => 'test',
'password' => 'StrongPass123',
'password_confirm' => 'StrongPass123',
'g-recaptcha-response' => 'captcha-token',
'bio' => 'honeypot',
];
$dispatched = [];
$eventDispatcher = new class($dispatched) {
public function __construct(private array &$dispatched)
{
}
public function dispatch(FOSSBilling\Events\Event $event): FOSSBilling\Events\Event
{
$this->dispatched[] = $event;
return $event;
}
};
$di = container();
$di['request'] = Symfony\Component\HttpFoundation\Request::create('http://localhost/', 'POST', server: ['REMOTE_ADDR' => '203.0.113.4']);
$di['event_dispatcher'] = $eventDispatcher;
$di['mod'] = $di->protect(static function (string $name): object {
$system = Mockery::mock(Box\Mod\System\Service::class);
$system->shouldReceive('getConfig')->once()->andReturn([]);
return $system;
});
$password = Mockery::mock(FOSSBilling\PasswordManager::class);
$password->shouldReceive('hashIt')->once()->with('StrongPass123')->andReturn('hashed');
$di['password'] = $password;
$entityManager = $di['em'];
$entityManager->shouldReceive('persist')->once()->andReturnUsing(static function (object $entity): void {
expect($entity)->toBeInstanceOf(Box\Mod\Client\Entity\Client::class);
(new ReflectionProperty(Box\Mod\Client\Entity\Client::class, 'id'))->setValue($entity, 43);
});
$service->setDi($di);
$client = $service->guestCreateClient($data);
expect($client->getId())->toBe(43);
expect($dispatched)->toHaveCount(2);
expect($dispatched[0])->toBeInstanceOf(BeforeClientSignUpEvent::class);
expect($dispatched[0]->input)->toBe([
'email' => 'test@unit.vm',
'first_name' => 'test',
'g-recaptcha-response' => 'captcha-token',
'bio' => 'honeypot',
'ip' => '203.0.113.4',
]);
expect($dispatched[1])->toEqual(new AfterClientSignUpEvent(43));
});
test('deleteGroup returns true', function (): void {
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Cron\Event;
use FOSSBilling\Events\Event;
/** Dispatched after the admin cron jobs finish. */
final class AfterAdminCronRunEvent extends Event
{
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Cron\Event;
use FOSSBilling\Events\Event;
/** Dispatched immediately before the admin cron jobs run. */
final class BeforeAdminCronRunEvent extends Event
{
}
+4 -3
View File
@@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Box\Mod\Cron;
use Box\Mod\Cron\Event\AfterAdminCronRunEvent;
use Box\Mod\Cron\Event\BeforeAdminCronRunEvent;
use FOSSBilling\Config;
use FOSSBilling\Environment;
use Symfony\Component\Filesystem\Path;
@@ -73,8 +75,7 @@ class Service
$this->di['logger']->withChannel('cron')->info('Started executing cron jobs.');
// @core tasks
$this->_exec($api, 'hook_batch_connect');
$this->di['events_manager']->fire(['event' => 'onBeforeAdminCronRun']);
$this->di['event_dispatcher']->dispatch(new BeforeAdminCronRunEvent());
$this->_exec($api, 'invoice_batch_pay_with_credits');
$this->_exec($api, 'invoice_batch_activate_paid');
@@ -118,7 +119,7 @@ class Service
$count = $this->clearOldSessions() ?? 0;
$this->di['logger']->withChannel('cron')->info("Cleared {$count} outdated sessions from the database.");
$this->di['events_manager']->fire(['event' => 'onAfterAdminCronRun']);
$this->di['event_dispatcher']->dispatch(new AfterAdminCronRunEvent());
if ($failedTasks !== []) {
$this->di['logger']->withChannel('cron')->warning('Finished executing cron jobs, but the following tasks failed: ' . implode(', ', $failedTasks) . '.');
+19 -5
View File
@@ -11,6 +11,7 @@
declare(strict_types=1);
use Box\Mod\Cron\Service;
use FOSSBilling\Events\Event;
use function Tests\Helpers\container;
@@ -97,9 +98,6 @@ test('runCrons isolates failures in core batch tasks', function (string $failedT
$updateFinalization = Mockery::mock();
$updateFinalization->shouldReceive('isRequired')->once()->andReturnFalse();
$eventsManager = Mockery::mock('\\Box_EventManager');
$eventsManager->shouldReceive('fire')->twice();
$systemService = Mockery::mock(Box\Mod\System\Service::class);
$systemService->shouldReceive('setParamValue')
->once()
@@ -110,10 +108,21 @@ test('runCrons isolates failures in core batch tasks', function (string $failedT
$api = new CronServiceApiDouble();
$api->throwOn = $failedTask;
$eventDispatcher = new class {
/** @var list<Event> */
public array $events = [];
public function dispatch(Event $event): Event
{
$this->events[] = $event;
return $event;
}
};
$di = container();
$di['api_system'] = $api;
$di['em']->shouldReceive('getConnection')->andReturn($connection);
$di['events_manager'] = $eventsManager;
$di['event_dispatcher'] = $eventDispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $systemService);
$di['update_finalization'] = $updateFinalization;
@@ -131,7 +140,12 @@ test('runCrons isolates failures in core batch tasks', function (string $failedT
->toBeLessThan($positions['invoice_batch_send_reminders'])
->and($positions['invoice_batch_send_reminders'])
->toBeLessThan($positions['invoice_batch_invoke_due_event'])
->and($api->methods)->toContain('email_batch_sendmail');
->and($api->methods)->toContain('email_batch_sendmail')
->and(array_map(static fn (Event $event): string => $event::class, $eventDispatcher->events))
->toBe([
Box\Mod\Cron\Event\BeforeAdminCronRunEvent::class,
Box\Mod\Cron\Event\AfterAdminCronRunEvent::class,
]);
})->with([
'invoice generation' => 'invoice_batch_generate',
'invoice reminders' => 'invoice_batch_send_reminders',
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Currency\Event;
use FOSSBilling\Events\Event;
final class AfterAdminDeleteCurrencyEvent extends Event
{
public function __construct(
public readonly string $code,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Currency\Event;
use FOSSBilling\Events\Event;
final class BeforeAdminDeleteCurrencyEvent extends Event
{
public function __construct(
public readonly string $code,
) {
}
}
+11 -11
View File
@@ -11,10 +11,14 @@ declare(strict_types=1);
namespace Box\Mod\Currency;
use Box\Mod\Cron\Event\BeforeAdminCronRunEvent;
use Box\Mod\Currency\Entity\Currency;
use Box\Mod\Currency\Event\AfterAdminDeleteCurrencyEvent;
use Box\Mod\Currency\Event\BeforeAdminDeleteCurrencyEvent;
use Box\Mod\Currency\Repository\CurrencyRepository;
use FOSSBilling\InformationException;
use FOSSBilling\InjectionAwareInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Intl\Currencies;
use Symfony\Contracts\Cache\ItemInterface;
use Twig\Extra\Intl\IntlExtension;
@@ -305,13 +309,13 @@ class Service implements InjectionAwareInterface
throw new InformationException('Cannot remove the default currency.');
}
$this->di['events_manager']->fire(['event' => 'onBeforeAdminDeleteCurrency', 'params' => ['code' => $currencyCode]]);
$this->di['event_dispatcher']->dispatch(new BeforeAdminDeleteCurrencyEvent($currencyCode));
$em = $this->di['em'];
$em->remove($currency);
$em->flush();
$this->di['events_manager']->fire(['event' => 'onAfterAdminDeleteCurrency', 'params' => ['code' => $currencyCode]]);
$this->di['event_dispatcher']->dispatch(new AfterAdminDeleteCurrencyEvent($currencyCode));
$this->di['logger']->info('Removed currency {currency_code}.', ['currency_code' => $currency->getCode()]);
@@ -747,19 +751,15 @@ class Service implements InjectionAwareInterface
/**
* If enabled, automatically call _getRate to fetch exchange rates whenever CRON jobs are run.
*/
public static function onBeforeAdminCronRun(\Box_Event $event): bool
#[AsEventListener]
public function updateRatesBeforeAdminCronRun(BeforeAdminCronRunEvent $event): void
{
$di = $event->getDi();
$currencyService = $di['mod_service']('currency');
try {
if ($currencyService->isCronEnabled()) {
$currencyService->updateCurrencyRates();
if ($this->isCronEnabled()) {
$this->updateCurrencyRates();
}
} catch (\Exception $e) {
$di['logger']->error($e->getMessage());
$this->di['logger']->error($e->getMessage());
}
return true;
}
}
+41 -15
View File
@@ -10,6 +10,10 @@
declare(strict_types=1);
use Box\Mod\Currency\Event\AfterAdminDeleteCurrencyEvent;
use Box\Mod\Currency\Event\BeforeAdminDeleteCurrencyEvent;
use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher;
use function Tests\Helpers\container;
test('di returns dependency injection container', function (): void {
@@ -391,19 +395,30 @@ test('removeCurrency removes currency', function (): void {
$emMock->shouldReceive('getRepository')
->atLeast()->once()
->andReturn($repositoryMock);
$emMock->shouldReceive('remove')
->atLeast()->once()
->with($model);
$emMock->shouldReceive('flush')
->atLeast()->once();
$events = new ArrayObject();
$eventDispatcher = new SymfonyEventDispatcher();
foreach ([BeforeAdminDeleteCurrencyEvent::class, AfterAdminDeleteCurrencyEvent::class] as $eventClass) {
$eventDispatcher->addListener($eventClass, static function (object $event) use ($events): void {
$events->append($event);
});
}
$eventsManager = Mockery::mock(Box_EventManager::class);
$eventsManager->shouldReceive('fire')
->twice();
$emMock->shouldReceive('remove')
->once()
->with($model)
->andReturnUsing(function () use ($events): void {
expect($events->getArrayCopy())->toHaveCount(1);
expect($events[0])->toBeInstanceOf(BeforeAdminDeleteCurrencyEvent::class);
});
$emMock->shouldReceive('flush')
->once()
->andReturnUsing(function () use ($events): void {
expect($events->getArrayCopy())->toHaveCount(1);
});
$di = new Pimple\Container();
$di['em'] = $emMock;
$di['events_manager'] = $eventsManager;
$di['event_dispatcher'] = $eventDispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$service = new Box\Mod\Currency\Service();
@@ -411,6 +426,10 @@ test('removeCurrency removes currency', function (): void {
$result = $service->removeCurrency('EUR');
expect($result)->toBeTrue();
expect(array_map(static fn (object $event): string => $event::class, $events->getArrayCopy()))
->toBe([BeforeAdminDeleteCurrencyEvent::class, AfterAdminDeleteCurrencyEvent::class]);
expect($events[0]->code)->toBe('EUR');
expect($events[1]->code)->toBe('EUR');
});
test('removeCurrency throws exception when currency is not found', function (): void {
@@ -770,6 +789,18 @@ test('updateCurrencyRates preserves manual overrides', function (): void {
expect($service->updateCurrencyRates())->toBeTrue();
});
test('updates currency rates before admin cron through a typed event listener', function (): void {
$service = Mockery::mock(Box\Mod\Currency\Service::class)->makePartial();
$service->shouldReceive('isCronEnabled')->once()->andReturn(true);
$service->shouldReceive('updateCurrencyRates')->once()->andReturn(true);
$dispatcher = new FOSSBilling\Events\EventDispatcher(
static fn (): array => ['currency'],
static fn (string $module): object => $service,
);
$dispatcher->dispatch(new Box\Mod\Cron\Event\BeforeAdminCronRunEvent());
});
test('removeCurrency deletes currency by code', function (): void {
$model = Mockery::mock(Box\Mod\Currency\Entity\Currency::class);
$model->shouldReceive('getCode')
@@ -795,15 +826,10 @@ test('removeCurrency deletes currency by code', function (): void {
$emMock->shouldReceive('flush')
->atLeast()->once();
$manager = Mockery::mock('Box_EventManager');
$manager->shouldReceive('fire')
->atLeast()->once()
->andReturn(true);
$di = new Pimple\Container();
$di['logger'] = new Tests\Helpers\TestLogger();
$di['em'] = $emMock;
$di['events_manager'] = $manager;
$di['event_dispatcher'] = new FOSSBilling\Events\EventDispatcher(static fn (): array => [], static fn (string $module): object => new stdClass());
$service = new Box\Mod\Currency\Service();
$service->setDi($di);
+1 -1
View File
@@ -204,7 +204,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
/**
* Create new email template. Creating new email template can be
* combined with custom event hook.
* combined with a typed event listener.
*
* @return int - newly created template id
*
@@ -592,7 +592,7 @@
</div>
<div class="modal-body">
<div class="alert alert-info" role="alert">
<strong class="me-1">{{ 'Information:'|trans }}</strong>{{ 'Newly created email templates can be used in custom event hooks.'|trans }}
<strong class="me-1">{{ 'Information:'|trans }}</strong>{{ 'Newly created email templates can be used by custom event listeners.'|trans }}
</div>
<div class="row g-3">
<div class="col-12">
+16 -8
View File
@@ -12,6 +12,14 @@ declare(strict_types=1);
namespace Box\Mod\Extension\Api;
use Box\Mod\Extension\Entity\Extension;
use Box\Mod\Extension\Event\AfterAdminDeactivateExtensionEvent;
use Box\Mod\Extension\Event\AfterAdminInstallExtensionEvent;
use Box\Mod\Extension\Event\AfterAdminUninstallExtensionEvent;
use Box\Mod\Extension\Event\AfterAdminUpdateExtensionEvent;
use Box\Mod\Extension\Event\BeforeAdminDeactivateExtensionEvent;
use Box\Mod\Extension\Event\BeforeAdminInstallExtensionEvent;
use Box\Mod\Extension\Event\BeforeAdminUninstallExtensionEvent;
use Box\Mod\Extension\Event\BeforeAdminUpdateExtensionEvent;
use FOSSBilling\Tools;
use FOSSBilling\Validation\Api\RequiredParams;
@@ -149,9 +157,9 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$ext = $this->_getExtension($data);
$service = $this->getService();
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminUpdateExtension', 'params' => $ext]);
$this->getDi()['event_dispatcher']->dispatch(new BeforeAdminUpdateExtensionEvent((int) $ext->getId(), $ext->getType(), $ext->getName()));
$ext2 = $service->update($ext);
$this->getDi()['events_manager']->fire(['event' => 'onAfterAdminUpdateExtension', 'params' => $ext2]);
$this->getDi()['event_dispatcher']->dispatch(new AfterAdminUpdateExtensionEvent((int) $ext2->getId(), $ext2->getType(), $ext2->getName()));
return $ext2;
}
@@ -187,12 +195,12 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$ext = $this->_getExtension($data);
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminDeactivateExtension', 'params' => ['id' => $ext->getId()]]);
$this->getDi()['event_dispatcher']->dispatch(new BeforeAdminDeactivateExtensionEvent((int) $ext->getId(), $ext->getType(), $ext->getName()));
$service = $this->getService();
$service->deactivate($ext);
$this->getDi()['events_manager']->fire(['event' => 'onAfterAdminDeactivateExtension', 'params' => ['id' => $data['id'], 'type' => $data['type']]]);
$this->getDi()['event_dispatcher']->dispatch(new AfterAdminDeactivateExtensionEvent((int) $ext->getId(), $ext->getType(), $ext->getName()));
$this->getDi()['logger']->info('Deactivated extension "{extension}"', ['extension' => $data['type'] . ' ' . $data['id']]);
@@ -207,11 +215,11 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('extension', 'uninstall_extensions');
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminUninstallExtension', 'params' => ['type' => $data['type'], 'id' => $data['id']]]);
$this->getDi()['event_dispatcher']->dispatch(new BeforeAdminUninstallExtensionEvent((string) $data['type'], (string) $data['id']));
$this->getService()->uninstall($data['type'], $data['id']);
$this->getDi()['events_manager']->fire(['event' => 'onAfterAdminUninstallExtension', 'params' => ['type' => $data['type'], 'id' => $data['id']]]);
$this->getDi()['event_dispatcher']->dispatch(new AfterAdminUninstallExtensionEvent((string) $data['type'], (string) $data['id']));
return true;
}
@@ -226,12 +234,12 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('extension', 'manage_extensions');
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminInstallExtension', 'params' => $data]);
$this->getDi()['event_dispatcher']->dispatch(new BeforeAdminInstallExtensionEvent((string) $data['type'], (string) $data['id']));
$service = $this->getService();
$service->downloadAndExtract($data['type'], $data['id']);
$this->getDi()['events_manager']->fire(['event' => 'onAfterAdminInstallExtension', 'params' => $data]);
$this->getDi()['event_dispatcher']->dispatch(new AfterAdminInstallExtensionEvent((string) $data['type'], (string) $data['id']));
return [
'success' => true,
+4 -5
View File
@@ -18,10 +18,10 @@ use FOSSBilling\Interfaces\ApiArrayInterface;
/**
* Installed extension record.
*
* The `extension` table tracks which extensions (modules, themes, payment
* gateways, hooks, ...) are installed in this FOSSBilling instance, along
* with their current status and version. The actual extension code lives
* on disk; this row is only the metadata used by the extension manager.
* The `extension` table tracks which extensions are installed in this
* FOSSBilling instance, along with their type, status, and version. The actual
* extension code lives on disk; this row is only the metadata used by the
* extension manager.
*/
#[ORM\Entity(repositoryClass: \Box\Mod\Extension\Repository\ExtensionRepository::class)]
#[ORM\Table(name: 'extension')]
@@ -36,7 +36,6 @@ class Extension implements ApiArrayInterface
final public const string TYPE_THEME = 'theme';
final public const string TYPE_PG = 'pg';
final public const string TYPE_TRANSLATION = 'translation';
final public const string TYPE_HOOK = 'hook';
final public const string STATUS_INSTALLED = 'installed';
final public const string STATUS_DEACTIVATED = 'deactivated';
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an extension has been activated. */
final class AfterAdminActivateExtensionEvent extends Event
{
public function __construct(
public readonly int $extensionRecordId,
public readonly ?string $extensionType,
public readonly ?string $extensionName,
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an extension has been deactivated. */
final class AfterAdminDeactivateExtensionEvent extends Event
{
public function __construct(
public readonly int $extensionRecordId,
public readonly ?string $extensionType,
public readonly ?string $extensionName,
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched after extension configuration is saved; values are intentionally omitted. */
final class AfterAdminExtensionConfigSaveEvent extends Event
{
/** @param list<string> $configurationKeys */
public function __construct(
public readonly string $extensionName,
public readonly array $configurationKeys,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched after extension files have been downloaded and extracted. */
final class AfterAdminInstallExtensionEvent extends Event
{
public function __construct(
public readonly string $extensionType,
public readonly string $extensionName,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched after extension files and module data have been uninstalled. */
final class AfterAdminUninstallExtensionEvent extends Event
{
public function __construct(
public readonly string $extensionType,
public readonly string $extensionName,
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an extension update request completes. */
final class AfterAdminUpdateExtensionEvent extends Event
{
public function __construct(
public readonly int $extensionRecordId,
public readonly ?string $extensionType,
public readonly ?string $extensionName,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
final class AfterExtensionActivatedEvent extends Event
{
public function __construct(
public readonly ?int $extensionId,
public readonly ?string $type,
public readonly ?string $name,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
final class AfterExtensionDeactivatedEvent extends Event
{
public function __construct(
public readonly ?int $extensionId,
public readonly ?string $type,
public readonly ?string $name,
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched before an extension is activated. */
final class BeforeAdminActivateExtensionEvent extends Event
{
public function __construct(
public readonly int $extensionRecordId,
public readonly ?string $extensionType,
public readonly ?string $extensionName,
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched before an extension is deactivated. */
final class BeforeAdminDeactivateExtensionEvent extends Event
{
public function __construct(
public readonly int $extensionRecordId,
public readonly ?string $extensionType,
public readonly ?string $extensionName,
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched before extension configuration is saved; values are intentionally omitted. */
final class BeforeAdminExtensionConfigSaveEvent extends Event
{
/** @param list<string> $configurationKeys */
public function __construct(
public readonly string $extensionName,
public readonly array $configurationKeys,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched before extension files are downloaded and extracted. */
final class BeforeAdminInstallExtensionEvent extends Event
{
public function __construct(
public readonly string $extensionType,
public readonly string $extensionName,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched before extension files and module data are uninstalled. */
final class BeforeAdminUninstallExtensionEvent extends Event
{
public function __construct(
public readonly string $extensionType,
public readonly string $extensionName,
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Extension\Event;
use FOSSBilling\Events\Event;
/** Dispatched before an extension update is requested. */
final class BeforeAdminUpdateExtensionEvent extends Event
{
public function __construct(
public readonly int $extensionRecordId,
public readonly ?string $extensionType,
public readonly ?string $extensionName,
) {
}
}
+35 -21
View File
@@ -11,12 +11,20 @@ declare(strict_types=1);
namespace Box\Mod\Extension;
use Box\Mod\Cron\Event\BeforeAdminCronRunEvent;
use Box\Mod\Extension\Entity\Extension;
use Box\Mod\Extension\Entity\ExtensionMeta;
use Box\Mod\Extension\Event\AfterAdminActivateExtensionEvent;
use Box\Mod\Extension\Event\AfterAdminExtensionConfigSaveEvent;
use Box\Mod\Extension\Event\AfterExtensionActivatedEvent;
use Box\Mod\Extension\Event\AfterExtensionDeactivatedEvent;
use Box\Mod\Extension\Event\BeforeAdminActivateExtensionEvent;
use Box\Mod\Extension\Event\BeforeAdminExtensionConfigSaveEvent;
use Box\Mod\Extension\Repository\ExtensionMetaRepository;
use Box\Mod\Extension\Repository\ExtensionRepository;
use FOSSBilling\Config;
use FOSSBilling\InjectionAwareInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
@@ -95,18 +103,14 @@ class Service implements InjectionAwareInterface
return $this->getExtensionRepository()->existsActiveByTypeAndName($type, $id);
}
public static function onBeforeAdminCronRun(\Box_Event $event): bool
#[AsEventListener]
public function refreshExtensionsOnCron(BeforeAdminCronRunEvent $event): void
{
$di = $event->getDi();
$extensionService = $di['mod_service']('extension');
try {
$extensionService->getExtensionsList([]);
$this->getExtensionsList([]);
} catch (\Exception $e) {
$di['logger']->error($e->getMessage());
$this->di['logger']->error($e->getMessage());
}
return true;
}
public function removeNotExistingModules(): int
@@ -415,6 +419,13 @@ class Service implements InjectionAwareInterface
$ext->setStatus(Extension::STATUS_INSTALLED);
$this->di['em']->flush();
if ($this->di->offsetExists('event_dispatcher')) {
if ($ext->getType() === \FOSSBilling\ExtensionManager::TYPE_MOD) {
$this->di['event_dispatcher']->refresh();
}
$this->di['event_dispatcher']->dispatch(new AfterExtensionActivatedEvent($ext->getId(), $ext->getType(), $ext->getName()));
}
return $result;
}
@@ -428,15 +439,6 @@ class Service implements InjectionAwareInterface
$this->di['mod_service']('Staff')->checkPermissionsAndThrowException('extension', 'manage_extensions');
switch ($ext->getType()) {
case \FOSSBilling\ExtensionManager::TYPE_HOOK:
$file = Path::changeExtension(ucfirst((string) $ext->getName()), '.php');
$destination = Path::join(PATH_LIBRARY, 'Hook', $file);
if ($this->filesystem->exists($destination)) {
$this->filesystem->remove($destination);
}
break;
case \FOSSBilling\ExtensionManager::TYPE_MOD:
$mod = $ext->getName();
if ($this->isCoreModule($mod)) {
@@ -452,6 +454,13 @@ class Service implements InjectionAwareInterface
$this->di['em']->remove($ext);
$this->di['em']->flush();
if ($this->di->offsetExists('event_dispatcher')) {
if ($ext->getType() === \FOSSBilling\ExtensionManager::TYPE_MOD) {
$this->di['event_dispatcher']->refresh();
}
$this->di['event_dispatcher']->dispatch(new AfterExtensionDeactivatedEvent($ext->getId(), $ext->getType(), $ext->getName()));
}
return true;
}
@@ -632,7 +641,7 @@ class Service implements InjectionAwareInterface
$persistedNewly = true;
}
$ext_id = $ext->getId();
$this->di['events_manager']->fire(['event' => 'onBeforeAdminActivateExtension', 'params' => ['id' => $ext_id]]);
$this->di['event_dispatcher']->dispatch(new BeforeAdminActivateExtensionEvent((int) $ext_id, $ext->getType(), $ext->getName()));
try {
$result = $this->activate($ext);
@@ -644,7 +653,7 @@ class Service implements InjectionAwareInterface
throw $e;
}
$this->di['events_manager']->fire(['event' => 'onAfterAdminActivateExtension', 'params' => ['id' => $ext_id]]);
$this->di['event_dispatcher']->dispatch(new AfterAdminActivateExtensionEvent((int) $ext_id, $ext->getType(), $ext->getName()));
$this->di['logger']->info('Activated extension "{data_id}"', ['data_id' => $data['id']]);
return $result;
@@ -683,7 +692,12 @@ class Service implements InjectionAwareInterface
$ext = $data['ext'];
$this->getConfig($ext); // Creates new config if it does not exist in DB
$this->di['events_manager']->fire(['event' => 'onBeforeAdminExtensionConfigSave', 'params' => $data]);
$configurationKeys = array_values(array_filter(
array_keys($data),
static fn (int|string $key): bool => is_string($key) && $key !== 'ext',
));
sort($configurationKeys);
$this->di['event_dispatcher']->dispatch(new BeforeAdminExtensionConfigSaveEvent($ext, $configurationKeys));
$meta = $this->getExtensionMetaRepository()->findOneByExtensionAndScope($ext, 'config');
$config = json_encode($data);
@@ -700,7 +714,7 @@ class Service implements InjectionAwareInterface
}
$this->di['em']->flush();
$this->di['events_manager']->fire(['event' => 'onAfterAdminExtensionConfigSave', 'params' => $data]);
$this->di['event_dispatcher']->dispatch(new AfterAdminExtensionConfigSaveEvent($ext, $configurationKeys));
$this->di['logger']->info("Updated extension {$ext} configuration.");
$this->di['cache']->delete("config_{$ext}");
@@ -1,7 +1,5 @@
{% extends request.ajax ? 'layout_blank.html.twig' : 'layout_default.html.twig' %}
{% set can_manage_hooks = has_permission('hook', 'manage_hooks') %}
{% block meta_title %}{{ 'Extensions'|trans }}{% endblock %}
{% set active_menu = 'extensions' %}
@@ -27,11 +25,6 @@
<li class="nav-item">
<a class="nav-link" href="#tab-about" data-bs-toggle="tab">{{ 'Information'|trans }}</a>
</li>
{% if can_manage_hooks %}
<li class="nav-item">
<a class="nav-link" href="#tab-hooks" data-bs-toggle="tab">{{ 'Hooks'|trans }}</a>
</li>
{% endif %}
</ul>
</div>
<div class="tab-content border-top">
@@ -164,7 +157,6 @@
<li>{{ 'Client Area Themes'|trans }}</li>
<li>{{ 'Admin Area Themes'|trans }}</li>
<li>{{ 'Translations for Client and Admin Areas'|trans }}</li>
<li>{{ 'Event Hooks - Hooks can be injected into the process, change its behaviour, stop executing actions'|trans }}</li>
<li>{{ 'API Module - External program which uses FOSSBilling API'|trans }}</li>
</ul>
</section>
@@ -176,40 +168,6 @@
</div>
</div>
</div>
{% if can_manage_hooks %}
<div class="tab-pane fade" id="tab-hooks" role="tabpanel" data-list-ns="">
<div class="card-body border-bottom py-3">
<p class="text-secondary mb-0">{{ 'Registered hooks let extensions integrate with key lifecycle events throughout FOSSBilling.'|trans }}</p>
</div>
<div class="table-responsive">
<table class="table card-table table-vcenter table-striped text-nowrap">
<thead>
<tr>
<th>{{ 'Extension'|trans }}</th>
{{ include('partial_sortable_th.html.twig', { 'label': 'Hook'|trans, 'sort_key': 'event', 'url': 'extension', 'hash': '#tab-hooks', 'default_sort': 'id', 'default_direction': 'ASC' }) }}
</tr>
</thead>
<tbody>
{% set hooks = admin.hook_get_list({ 'per_page': 25, 'page': request.page }|merge(request)) %}
{% for hook in hooks.list %}
<tr>
<td>
{{ hook.rel_id|capitalize }}
</td>
<td>
<code>{{ hook.event }}</code>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-footer d-flex align-items-center">
{{ include('partial_pagination.html.twig', { 'list': hooks, 'url': 'extension', 'hash': '#tab-hooks', 'split_footer_controls': true }) }}
</div>
</div>
{% endif %}
</div>
</div>
</div>
@@ -10,7 +10,21 @@
declare(strict_types=1);
use Box\Mod\Extension\Entity\Extension;
use Box\Mod\Extension\Event\AfterAdminDeactivateExtensionEvent;
use Box\Mod\Extension\Event\AfterAdminInstallExtensionEvent;
use Box\Mod\Extension\Event\AfterAdminUninstallExtensionEvent;
use Box\Mod\Extension\Event\AfterAdminUpdateExtensionEvent;
use Box\Mod\Extension\Event\BeforeAdminDeactivateExtensionEvent;
use Box\Mod\Extension\Event\BeforeAdminInstallExtensionEvent;
use Box\Mod\Extension\Event\BeforeAdminUninstallExtensionEvent;
use Box\Mod\Extension\Event\BeforeAdminUpdateExtensionEvent;
use Box\Mod\Extension\Repository\ExtensionRepository;
use Box\Mod\Extension\Service;
use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher;
use function Tests\Helpers\container;
use function Tests\Helpers\setEntityId;
test('getDi returns the dependency injection container', function (): void {
$api = apiEndpoint(new Box\Mod\Extension\Api\Admin());
@@ -22,7 +36,7 @@ test('getDi returns the dependency injection container', function (): void {
test('activate activates an extension', function (): void {
$api = apiEndpoint(new Box\Mod\Extension\Api\Admin());
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('activateExistingExtension')
->atLeast()
@@ -35,9 +49,165 @@ test('activate activates an extension', function (): void {
expect($result)->toBeArray();
});
test('install dispatches typed lifecycle events with only extension identity', function (): void {
$api = apiEndpoint(new Box\Mod\Extension\Api\Admin());
$steps = [];
$service = Mockery::mock(Service::class);
$service->shouldReceive('downloadAndExtract')
->once()
->with('mod', 'example')
->andReturnUsing(function () use (&$steps): bool {
$steps[] = 'download';
return true;
});
$dispatcher = new SymfonyEventDispatcher();
$dispatcher->addListener(BeforeAdminInstallExtensionEvent::class, static function (BeforeAdminInstallExtensionEvent $event) use (&$steps): void {
$steps[] = $event;
});
$dispatcher->addListener(AfterAdminInstallExtensionEvent::class, static function (AfterAdminInstallExtensionEvent $event) use (&$steps): void {
$steps[] = $event;
});
$di = $api->getDi();
$di['event_dispatcher'] = $dispatcher;
$api->setDi($di);
$api->setService($service);
$result = $api->install(['id' => 'example', 'type' => 'mod', 'api_key' => 'secret']);
expect($result)->toBe(['success' => true, 'id' => 'example', 'type' => 'mod'])
->and($steps)->toHaveCount(3)
->and($steps[0])->toBeInstanceOf(BeforeAdminInstallExtensionEvent::class)
->and($steps[0]->extensionType)->toBe('mod')
->and($steps[0]->extensionName)->toBe('example')
->and($steps[1])->toBe('download')
->and($steps[2])->toBeInstanceOf(AfterAdminInstallExtensionEvent::class);
});
test('uninstall dispatches typed lifecycle events around service cleanup', function (): void {
$api = apiEndpoint(new Box\Mod\Extension\Api\Admin());
$steps = [];
$service = Mockery::mock(Service::class);
$service->shouldReceive('uninstall')
->once()
->with('mod', 'example')
->andReturnUsing(function () use (&$steps): bool {
$steps[] = 'uninstall';
return true;
});
$dispatcher = new SymfonyEventDispatcher();
$dispatcher->addListener(BeforeAdminUninstallExtensionEvent::class, static function (BeforeAdminUninstallExtensionEvent $event) use (&$steps): void {
$steps[] = $event;
});
$dispatcher->addListener(AfterAdminUninstallExtensionEvent::class, static function (AfterAdminUninstallExtensionEvent $event) use (&$steps): void {
$steps[] = $event;
});
$di = $api->getDi();
$di['event_dispatcher'] = $dispatcher;
$api->setDi($di);
$api->setService($service);
expect($api->uninstall(['id' => 'example', 'type' => 'mod']))->toBeTrue()
->and($steps)->toHaveCount(3)
->and($steps[0])->toBeInstanceOf(BeforeAdminUninstallExtensionEvent::class)
->and($steps[0]->extensionType)->toBe('mod')
->and($steps[0]->extensionName)->toBe('example')
->and($steps[1])->toBe('uninstall')
->and($steps[2])->toBeInstanceOf(AfterAdminUninstallExtensionEvent::class);
});
test('deactivate dispatches typed events around service deactivation', function (): void {
$api = apiEndpoint(new Box\Mod\Extension\Api\Admin());
$extension = new Extension();
setEntityId($extension, 52);
$extension->setType('mod');
$extension->setName('example');
$repository = Mockery::mock(ExtensionRepository::class);
$repository->shouldReceive('findOneByTypeAndName')
->once()
->with('mod', 'example')
->andReturn($extension);
$steps = [];
$service = Mockery::mock(Service::class);
$service->shouldReceive('getExtensionRepository')->once()->andReturn($repository);
$service->shouldReceive('deactivate')
->once()
->with($extension)
->andReturnUsing(function () use (&$steps): bool {
$steps[] = 'deactivate';
return true;
});
$dispatcher = new SymfonyEventDispatcher();
$dispatcher->addListener(BeforeAdminDeactivateExtensionEvent::class, static function (BeforeAdminDeactivateExtensionEvent $event) use (&$steps): void {
$steps[] = $event;
});
$dispatcher->addListener(AfterAdminDeactivateExtensionEvent::class, static function (AfterAdminDeactivateExtensionEvent $event) use (&$steps): void {
$steps[] = $event;
});
$di = $api->getDi();
$di['event_dispatcher'] = $dispatcher;
$api->setDi($di);
$api->setService($service);
expect($api->deactivate(['id' => 'example', 'type' => 'mod']))->toBeTrue()
->and($steps)->toHaveCount(3)
->and($steps[0])->toBeInstanceOf(BeforeAdminDeactivateExtensionEvent::class)
->and($steps[0]->extensionRecordId)->toBe(52)
->and($steps[0]->extensionType)->toBe('mod')
->and($steps[0]->extensionName)->toBe('example')
->and($steps[1])->toBe('deactivate')
->and($steps[2])->toBeInstanceOf(AfterAdminDeactivateExtensionEvent::class)
->and($steps[2]->extensionRecordId)->toBe(52);
});
test('update dispatches the typed before event before the current unsupported update failure', function (): void {
$api = apiEndpoint(new Box\Mod\Extension\Api\Admin());
$extension = new Extension();
setEntityId($extension, 53);
$extension->setType('mod');
$extension->setName('example');
$repository = Mockery::mock(ExtensionRepository::class);
$repository->shouldReceive('findOneByTypeAndName')
->once()
->with('mod', 'example')
->andReturn($extension);
$steps = [];
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('getExtensionRepository')->once()->andReturn($repository);
$service->shouldReceive('update')
->once()
->with($extension)
->andThrow(new FOSSBilling\InformationException('Update is unavailable'));
$dispatcher = new SymfonyEventDispatcher();
$dispatcher->addListener(BeforeAdminUpdateExtensionEvent::class, static function (BeforeAdminUpdateExtensionEvent $event) use (&$steps): void {
$steps[] = $event;
});
$dispatcher->addListener(AfterAdminUpdateExtensionEvent::class, static function (AfterAdminUpdateExtensionEvent $event) use (&$steps): void {
$steps[] = $event;
});
$di = $api->getDi();
$di['event_dispatcher'] = $dispatcher;
$api->setDi($di);
$api->setService($service);
expect(fn () => $api->update(['id' => 'example', 'type' => 'mod']))
->toThrow(FOSSBilling\InformationException::class)
->and($steps)->toHaveCount(1)
->and($steps[0])->toBeInstanceOf(BeforeAdminUpdateExtensionEvent::class)
->and($steps[0]->extensionRecordId)->toBe(53)
->and($steps[0]->extensionName)->toBe('example');
});
test('configGet gets extension config', function (): void {
$api = apiEndpoint(new Box\Mod\Extension\Api\Admin());
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getConfig')
->atLeast()
@@ -59,7 +229,7 @@ test('configGet gets extension config', function (): void {
test('configSave saves extension config', function (): void {
$api = apiEndpoint(new Box\Mod\Extension\Api\Admin());
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('hasManagePermission')
->with('mod_example')
@@ -79,7 +249,7 @@ test('configSave saves extension config', function (): void {
test('getList returns extensions list', function (): void {
$api = apiEndpoint(new Box\Mod\Extension\Api\Admin());
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getExtensionsList')
->atLeast()
@@ -94,7 +264,7 @@ test('getList returns extensions list', function (): void {
test('getNavigation returns admin navigation', function (): void {
$api = apiEndpoint(new Box\Mod\Extension\Api\Admin());
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getAdminNavigation')
->atLeast()
+146 -54
View File
@@ -12,9 +12,18 @@ declare(strict_types=1);
use Box\Mod\Extension\Entity\Extension;
use Box\Mod\Extension\Entity\ExtensionMeta;
use Box\Mod\Extension\Event\AfterAdminActivateExtensionEvent;
use Box\Mod\Extension\Event\AfterAdminExtensionConfigSaveEvent;
use Box\Mod\Extension\Event\AfterExtensionActivatedEvent;
use Box\Mod\Extension\Event\AfterExtensionDeactivatedEvent;
use Box\Mod\Extension\Event\BeforeAdminActivateExtensionEvent;
use Box\Mod\Extension\Event\BeforeAdminExtensionConfigSaveEvent;
use Box\Mod\Extension\Repository\ExtensionMetaRepository;
use Box\Mod\Extension\Repository\ExtensionRepository;
use Box\Mod\Extension\Service;
use Box\Mod\Widgets\Service as WidgetsService;
use FOSSBilling\Events\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher;
use function Tests\Helpers\container;
use function Tests\Helpers\setEntityId;
@@ -374,13 +383,41 @@ test('activate activates an extension', function (): void {
->atLeast()
->once();
$activeModules = [];
$flushes = 0;
$em = extensionBuildEm();
$em->shouldReceive('flush')->atLeast()->once();
$em->shouldReceive('flush')->twice()->andReturnUsing(function () use (&$activeModules, &$flushes): void {
++$flushes;
if ($flushes === 2) {
$activeModules = ['widgets'];
}
});
$di = container();
$di['em'] = $em;
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
$cache = new class {
public array $deleted = [];
public function delete(string $key): bool
{
$this->deleted[] = $key;
return true;
}
};
$di['cache'] = $cache;
$widgets = new WidgetsService();
$widgets->setDi($di);
$dispatcher = new EventDispatcher(
static function () use (&$activeModules): array {
return $activeModules;
},
static fn (string $module): object => $widgets,
);
$dispatcher->dispatch(new AfterExtensionActivatedEvent(0, 'theme', 'warmup'));
$di['event_dispatcher'] = $dispatcher;
$service->setDi($di);
$result = $service->activate($ext);
@@ -389,11 +426,12 @@ test('activate activates an extension', function (): void {
expect($result['type'])->toBe('mod');
expect($result['redirect'])->toBeTrue();
expect($result['has_settings'])->toBeTrue();
expect($cache->deleted)->toBe([WidgetsService::CACHE_KEY]);
});
test('deactivate deactivates an extension', function (): void {
$service = new Service();
$ext = extensionCreateEntity(1, 'mod', 'extensionTest', 'installed');
$ext = extensionCreateEntity(1, 'mod', 'widgets', 'installed');
$modMock = Mockery::mock(FOSSBilling\Module::class);
$modMock->shouldReceive('getCoreModules')
@@ -404,19 +442,44 @@ test('deactivate deactivates an extension', function (): void {
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
$activeModules = ['widgets'];
$em = extensionBuildEm();
$em->shouldReceive('remove')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$em->shouldReceive('flush')->once()->andReturnUsing(function () use (&$activeModules): void {
$activeModules = [];
});
$di = container();
$di['em'] = $em;
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
$cache = new class {
public array $deleted = [];
public function delete(string $key): bool
{
$this->deleted[] = $key;
return true;
}
};
$di['cache'] = $cache;
$widgets = new WidgetsService();
$widgets->setDi($di);
$dispatcher = new EventDispatcher(
static function () use (&$activeModules): array {
return $activeModules;
},
static fn (string $module): object => $widgets,
);
$dispatcher->dispatch(new AfterExtensionDeactivatedEvent(0, 'theme', 'warmup'));
$di['event_dispatcher'] = $dispatcher;
$service->setDi($di);
$result = $service->deactivate($ext);
expect($result)->toBeTrue();
expect($cache->deleted)->toBeEmpty();
});
test('deactivate throws exception for core modules', function (): void {
@@ -445,31 +508,6 @@ test('deactivate throws exception for core modules', function (): void {
->toThrow(FOSSBilling\Exception::class, 'Core modules are an integral part of the FOSSBilling system and cannot be deactivated.');
});
test('deactivate deactivates hook extension', function (): void {
$ext = extensionCreateEntity(1, 'hook', 'extensionTest', 'installed');
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
$filesystemMock = Mockery::mock(Symfony\Component\Filesystem\Filesystem::class);
$filesystemMock->shouldReceive('exists')->atLeast()->once()->andReturn(true);
$filesystemMock->shouldReceive('remove')->atLeast()->once();
$service = new Service($filesystemMock);
$em = extensionBuildEm();
$em->shouldReceive('remove')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$di = container();
$di['em'] = $em;
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
$service->setDi($di);
$result = $service->deactivate($ext);
expect($result)->toBeTrue();
});
test('deactivate deactivates module', function (): void {
$ext = extensionCreateEntity(1, 'mod', 'extensionTest', 'installed');
@@ -621,35 +659,51 @@ test('activateExistingExtension activates existing extension', function (): void
'type' => 'extensionType',
];
$steps = [];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('activate')
->atLeast()
->once()
->andReturn([]);
->andReturnUsing(function () use (&$steps): array {
$steps[] = 'activate';
return [];
});
$extension = extensionCreateEntity(42, 'mod', 'extensionId', Extension::STATUS_DEACTIVATED);
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('findOneByTypeAndName')
->atLeast()
->once()
->andReturn(null);
->with('extensionType', 'extensionId')
->andReturn($extension);
$em = extensionBuildEm($extensionRepository);
$em->shouldReceive('persist')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$dispatcher = new SymfonyEventDispatcher();
$dispatcher->addListener(BeforeAdminActivateExtensionEvent::class, static function (BeforeAdminActivateExtensionEvent $event) use (&$steps): void {
$steps[] = $event;
});
$dispatcher->addListener(AfterAdminActivateExtensionEvent::class, static function (AfterAdminActivateExtensionEvent $event) use (&$steps): void {
$steps[] = $event;
});
$di = container();
$di['em'] = $em;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $dispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$serviceMock->setDi($di);
$result = $serviceMock->activateExistingExtension($data);
expect($result)->toBeArray();
expect($result)->toBeArray()
->and($steps)->toHaveCount(3)
->and($steps[0])->toBeInstanceOf(BeforeAdminActivateExtensionEvent::class)
->and($steps[0]->extensionRecordId)->toBe(42)
->and($steps[0]->extensionType)->toBe('mod')
->and($steps[0]->extensionName)->toBe('extensionId')
->and($steps[1])->toBe('activate')
->and($steps[2])->toBeInstanceOf(AfterAdminActivateExtensionEvent::class)
->and($steps[2]->extensionRecordId)->toBe(42);
});
test('activateExistingExtension requires type and id', function (): void {
@@ -685,12 +739,8 @@ test('activateExistingExtension accepts zero-like identifiers', function (): voi
->once()
->andReturn([]);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['em'] = $em;
$di['events_manager'] = $eventMock;
$di['logger'] = new Tests\Helpers\TestLogger();
$serviceMock->setDi($di);
@@ -719,19 +769,28 @@ test('activateExistingExtension throws exception on activation failure', functio
->once()
->andReturn($model);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$events = [];
$dispatcher = new SymfonyEventDispatcher();
$dispatcher->addListener(BeforeAdminActivateExtensionEvent::class, static function (BeforeAdminActivateExtensionEvent $event) use (&$events): void {
$events[] = $event;
});
$dispatcher->addListener(AfterAdminActivateExtensionEvent::class, static function (AfterAdminActivateExtensionEvent $event) use (&$events): void {
$events[] = $event;
});
$em = extensionBuildEm($extensionRepository);
$di = container();
$di['em'] = $em;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $dispatcher;
$serviceMock->setDi($di);
expect(fn () => $serviceMock->activateExistingExtension($data))
->toThrow(Exception::class);
->toThrow(Exception::class)
->and($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(BeforeAdminActivateExtensionEvent::class)
->and($events[0]->extensionRecordId)->toBe(1);
});
test('getConfig returns extension config', function (): void {
@@ -794,6 +853,10 @@ test('getConfig creates new ExtensionMeta when not found', function (): void {
test('setConfig sets extension config', function (): void {
$data = [
'ext' => 'extensionName',
'username' => 'admin',
'api_key' => 'secret-api-key',
'password' => 'secret-password',
'nested' => ['credential' => 'nested-secret'],
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
@@ -811,10 +874,15 @@ test('setConfig sets extension config', function (): void {
$toolsMock = Mockery::mock(FOSSBilling\Tools::class);
$cryptMock = Mockery::mock(FOSSBilling\Crypt::class);
$encryptedConfig = null;
$cryptMock->shouldReceive('encrypt')
->atLeast()
->once()
->andReturn('encryptedConfig');
->with(json_encode($data), Mockery::type('string'))
->andReturnUsing(function (string $config, string $salt) use (&$encryptedConfig): string {
$encryptedConfig = $config;
return 'encryptedConfig';
});
$metaRepo = Mockery::mock(ExtensionMetaRepository::class);
$metaRepo->shouldReceive('findOneByExtensionAndScope')
@@ -824,23 +892,40 @@ test('setConfig sets extension config', function (): void {
$em = extensionBuildEm(null, $metaRepo);
$em->shouldReceive('persist')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$flushed = false;
$em->shouldReceive('flush')->once()->andReturnUsing(function () use (&$flushed): void {
$flushed = true;
});
$events = [];
$dispatcher = new SymfonyEventDispatcher();
$dispatcher->addListener(BeforeAdminExtensionConfigSaveEvent::class, static function (BeforeAdminExtensionConfigSaveEvent $event) use (&$events): void {
$events[] = [$event, false];
});
$dispatcher->addListener(AfterAdminExtensionConfigSaveEvent::class, static function (AfterAdminExtensionConfigSaveEvent $event) use (&$events, &$flushed): void {
$events[] = [$event, $flushed];
});
$di = container();
$di['em'] = $em;
$di['tools'] = $toolsMock;
$di['crypt'] = $cryptMock;
$di['events_manager'] = $eventMock;
$di['event_dispatcher'] = $dispatcher;
$di['logger'] = new Tests\Helpers\TestLogger();
$di['cache'] = new Symfony\Component\Cache\Adapter\ArrayAdapter();
$serviceMock->setDi($di);
$result = $serviceMock->setConfig($data);
expect($result)->toBeTrue();
expect($result)->toBeTrue()
->and($encryptedConfig)->toBe(json_encode($data))
->and($events)->toHaveCount(2)
->and($events[0][0])->toBeInstanceOf(BeforeAdminExtensionConfigSaveEvent::class)
->and($events[0][0]->extensionName)->toBe('extensionName')
->and($events[0][0]->configurationKeys)->toBe(['api_key', 'nested', 'password', 'username'])
->and($events[0][1])->toBeFalse()
->and($events[1][0])->toBeInstanceOf(AfterAdminExtensionConfigSaveEvent::class)
->and($events[1][0]->configurationKeys)->toBe(['api_key', 'nested', 'password', 'username'])
->and($events[1][1])->toBeTrue();
});
test('hasManagePermission denies access when module does not declare manage_settings', function (): void {
@@ -1042,3 +1127,10 @@ test('setConfig denies inactive module configuration without touching storage wh
expect(fn () => $serviceMock->setConfig(['ext' => 'mod_support']))
->toThrow(new FOSSBilling\InformationException('You need the "extension.manage_extensions" permission to perform this action', [], 403));
});
test('the typed cron listener refreshes the extension list', function (): void {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('getExtensionsList')->once()->with([])->andReturn([]);
$service->refreshExtensionsOnCron(new Box\Mod\Cron\Event\BeforeAdminCronRunEvent());
});
-90
View File
@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2025 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
/**
* Hooks management module.
*/
namespace Box\Mod\Hook\Api;
use FOSSBilling\PaginationOptions;
class Admin extends \FOSSBilling\Api\AbstractApi
{
/**
* Get paginated list of hooks.
*
* @optional string $sort - sort by one of: id, event, created_at, updated_at
* @optional string $direction - sort direction: ASC or DESC
*
* @return array
*/
public function get_list($data)
{
$this->checkPermissions('hook', 'view');
$service = $this->getService();
[$sql, $params] = $service->getSearchQuery($data);
return $this->getDi()['pager']->getPaginatedResultSet($sql, $params, PaginationOptions::fromArray($data));
}
/**
* Invoke hook with params.
*
* @optional array $params - what params are passed to event method $event->getParams()
*
* @return mixed - event return value
*/
public function call($data)
{
$this->checkPermissions('hook', 'trigger_hooks');
if (!isset($data['event']) || empty($data['event'])) {
$this->getDi()['logger']->warning('Invoked event call without providing event name');
return false;
}
$event = $data['event'];
$params = $data['params'] ?? null;
// @phpstan-ignore if.alwaysFalse
if (DEBUG) {
try {
$this->getDi()['logger']->info($event . ': ' . var_export($params, true));
} catch (\Throwable $e) {
// The application logger failed; retain a PHP fallback rather than
// masking hook dispatch with a logging failure.
error_log('[Hook] logger failure: ' . $e->getMessage());
}
}
return $this->getDi()['events_manager']->fire($data);
}
/**
* Reinstall and activate all existing hooks from module or all
* activated modules. Does not connect already connected event.
*
* @optional string $mod - module name to connect hooks
*
* @return bool
*/
public function batch_connect($data)
{
$this->checkPermissions('hook', 'manage_hooks');
$mod = $data['mod'] ?? null;
$service = $this->getService();
return $service->batchConnect($mod);
}
}
-308
View File
@@ -1,308 +0,0 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2025 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
namespace Box\Mod\Hook;
use Box\Mod\Extension\Entity\Extension;
use Box\Mod\Extension\Entity\ExtensionMeta;
use Box\Mod\Extension\Repository\ExtensionRepository;
use FOSSBilling\Doctrine\NamedLock;
use FOSSBilling\InjectionAwareInterface;
use FOSSBilling\SortOptions;
class Service implements InjectionAwareInterface
{
private const string BATCH_CONNECT_LOCK = 'fossbilling_hook_batch_connect';
protected ?\Pimple\Container $di = null;
public function setDi(\Pimple\Container $di): void
{
$this->di = $di;
}
public function getDi(): ?\Pimple\Container
{
return $this->di;
}
public function getModulePermissions(): array
{
return [
'view' => [
'type' => 'bool',
'display_name' => __trans('View hooks'),
'description' => __trans('Allows the staff member to view registered event hooks.'),
],
'manage_hooks' => [
'type' => 'bool',
'display_name' => __trans('Manage hooks'),
'description' => __trans('Allows the staff member to reconnect registered event hooks.'),
],
'trigger_hooks' => [
'type' => 'bool',
'display_name' => __trans('Trigger hooks'),
'description' => __trans('Allows the staff member to invoke hooks manually with custom event payloads.'),
],
];
}
public function getSearchQuery($filter): array
{
$q = "SELECT id, rel_type, rel_id, meta_value as event, created_at, updated_at
FROM extension_meta
WHERE extension = 'mod_hook'
AND rel_type = 'mod'
AND meta_key = 'listener'
";
$sort = SortOptions::fromArray(is_array($filter) ? $filter : [], [
'id' => 'id',
'event' => 'meta_value',
'created_at' => 'created_at',
'updated_at' => 'updated_at',
]);
$orderBy = $sort->toOrderByClause('id') ?? 'id ASC';
$q .= " ORDER BY {$orderBy}";
return [$q, []];
}
/**
* Whether any module event listener has ever been connected.
*
* Listeners are normally (re)connected by the cron job's hook_batch_connect task.
* Before cron has run for the first time (e.g. right after a fresh install), this
* is false and every event fired by the application silently has no listeners.
*/
public function hasConnectedListeners(): bool
{
$q = "SELECT 1
FROM extension_meta
WHERE extension = 'mod_hook'
AND rel_type = 'mod'
AND meta_key = 'listener'
LIMIT 1
";
return (bool) $this->di['em']->getConnection()->fetchOne($q);
}
public function toApiArray($row)
{
return $row;
}
public static function onAfterAdminActivateExtension(\Box_Event $event): void
{
$params = $event->getParameters();
if (!isset($params['id'])) {
$event->setReturnValue(false);
} else {
$di = $event->getDi();
$ext = $di['em']->getRepository(Extension::class)->find((int) $params['id']);
if ($ext !== null && $ext->getType() === Extension::TYPE_MOD) {
$service = $di['mod_service']('hook');
$service->batchConnect($ext->getName());
}
$event->setReturnValue(true);
}
}
public static function onAfterAdminDeactivateExtension(\Box_Event $event): void
{
$di = $event->getDi();
$params = $event->getParameters();
if ($params['type'] == 'mod') {
$q = "DELETE FROM extension_meta
WHERE extension = 'mod_hook'
AND rel_type = 'mod'
AND rel_id = :mod
AND meta_key = 'listener'";
$di['em']->getConnection()->executeStatement($q, ['mod' => $params['id']]);
$di['events_manager']->clearListenerCache();
}
$event->setReturnValue(true);
}
/**
* Serializes batchConnect() runs so two concurrent callers (e.g. overlapping cron and
* on-demand rebuilds triggered by a login) cannot interleave connect()'s check-then-insert
* and create duplicate listener rows. The rebuild itself runs in a single DB transaction,
* so hasConnectedListeners() can only ever observe the previous complete set or the new
* complete set, never a partially rebuilt one.
*
* Returns false if the lock could not be acquired within the timeout, so a failure to
* initialize is never mistaken for success - callers that need listeners connected before
* proceeding should check the return value rather than assume this always succeeds.
*/
public function batchConnect($mod_name = null): bool
{
$connection = $this->di['em']->getConnection();
if (!NamedLock::acquire($connection, self::BATCH_CONNECT_LOCK, 5)) {
// Another process is already rebuilding the listener set and holding the lock
// past our wait. Report failure rather than claiming a rebuild we didn't run or
// wait for actually completed.
return false;
}
try {
$connection->transactional(function () use ($mod_name): void {
// Clean up the existing list before we add to it
$this->_disconnectUnavailable();
$extensionService = $this->di['mod_service']('extension');
$mods = [];
if ($mod_name !== null) {
$mods[] = $mod_name;
} else {
$mods = $extensionService->getCoreAndActiveModules();
}
foreach ($mods as $m) {
$installed = $this->getExtensionRepository()->existsActiveByTypeAndName(Extension::TYPE_MOD, $m);
if (!$installed && !$extensionService->isCoreModule($m)) {
continue;
}
$mod = $this->di['mod']($m);
if ($mod->hasService()) {
$class = $mod->getService();
$reflector = new \ReflectionClass($class);
foreach ($reflector->getMethods() as $method) {
if ($this->canBeConnected($method)) {
$this->connect(['event' => $method->getName(), 'mod' => $mod->getName()]);
}
}
}
}
});
$this->di['events_manager']->clearListenerCache();
return true;
} finally {
NamedLock::release($connection, self::BATCH_CONNECT_LOCK);
}
}
private function canBeConnected(\ReflectionMethod $method): bool
{
$parameters = $method->getParameters();
if (!isset($parameters[0]) || !$method->isPublic()) {
return false;
}
$type = $parameters[0]->getType() instanceof \ReflectionNamedType ? $parameters[0]->getType()->getName() : null;
if ($type == 'Box_Event' || $type == "\Box_Event") {
return true;
}
return false;
}
/**
* Connect event for module.
*
* @throws \FOSSBilling\Exception
*/
private function connect($data): bool
{
$required = [
'event' => 'Hook event not passed',
'mod' => 'Param mod not passed',
];
$this->di['validator']->checkRequiredParamsForArray($required, $data);
$event = $data['event'];
$mod = $data['mod'];
$q = "SELECT id
FROM extension_meta
WHERE extension = 'mod_hook'
AND rel_type = 'mod'
AND rel_id = :mod
AND meta_key = 'listener'
AND meta_value = :event
";
if ($this->di['em']->getConnection()->fetchOne($q, ['mod' => $mod, 'event' => $event])) {
// already connected
return true;
}
$meta = new ExtensionMeta();
$meta->setExtension('mod_hook');
$meta->setRelType('mod');
$meta->setRelId($mod);
$meta->setMetaKey('listener');
$meta->setMetaValue($event);
$this->di['em']->persist($meta);
$this->di['em']->flush();
return true;
}
/**
* Disconnect unavailable listeners.
*/
private function _disconnectUnavailable(): void
{
$rm_sql = 'DELETE FROM extension_meta WHERE id = :id';
$sql = "SELECT id, rel_id, meta_value
FROM extension_meta
WHERE extension = 'mod_hook'
AND rel_type = 'mod'
AND meta_key = 'listener'
";
$list = $this->di['em']->getConnection()->fetchAllAssociative($sql);
$extensionService = $this->di['mod_service']('extension');
foreach ($list as $listener) {
try {
$mod_name = $listener['rel_id'];
$event = $listener['meta_value'];
// disconnect modules without service class
$mod = $this->di['mod']($mod_name);
if (!$mod->hasService()) {
$this->di['em']->getConnection()->executeStatement($rm_sql, ['id' => $listener['id']]);
continue;
}
// Remove listeners that don't exist or aren't actually hooks
$s = $mod->getService();
$reflector = new \ReflectionClass($s);
if (!$reflector->hasMethod($event) || !$this->canBeConnected($reflector->getMethod($event))) {
$this->di['em']->getConnection()->executeStatement($rm_sql, ['id' => $listener['id']]);
continue;
}
// If the listener is for a module that's not installed and is **not** a core module, remove the listener
$installed = $this->getExtensionRepository()->existsActiveByTypeAndName(Extension::TYPE_MOD, $mod_name);
if (!$installed && !$extensionService->isCoreModule($mod_name)) {
$this->di['em']->getConnection()->executeStatement($rm_sql, ['id' => $listener['id']]);
continue;
}
} catch (\Exception $e) {
$this->di['logger']->error($e->getMessage());
}
}
}
private function getExtensionRepository(): ExtensionRepository
{
return $this->di['em']->getRepository(Extension::class);
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"id": "hook",
"type": "mod",
"name": "Event hooks subsystem",
"description": "FOSSBilling core module",
"version": "1.0.0"
}
@@ -1,90 +0,0 @@
<?php
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
declare(strict_types=1);
use function Tests\Helpers\container;
test('getDi returns dependency injection container', function (): void {
$api = apiEndpoint(new Box\Mod\Hook\Api\Admin());
$di = container();
$api->setDi($di);
$getDi = $api->getDi();
expect($getDi)->toEqual($di);
});
test('getList returns array', function (): void {
$api = apiEndpoint(new Box\Mod\Hook\Api\Admin());
$serviceMock = Mockery::mock(Box\Mod\Hook\Service::class);
/** @var Mockery\Expectation $expectation1 */
$expectation1 = $serviceMock->shouldReceive('getSearchQuery');
$expectation1->atLeast()->once();
$expectation1->andReturn(['SqlString', []]);
$paginatorMock = Mockery::mock(FOSSBilling\Pagination::class)->makePartial();
$paginatorMock->shouldAllowMockingProtectedMethods();
/** @var Mockery\Expectation $expectation2 */
$expectation2 = $paginatorMock->shouldReceive('getPaginatedResultSet');
$expectation2->atLeast()->once();
$expectation2->andReturn([]);
$di = container();
$di['pager'] = $paginatorMock;
$api->setDi($di);
$api->setService($serviceMock);
$result = $api->get_list([]);
expect($result)->toBeArray();
});
test('call fires event', function (): void {
$api = apiEndpoint(new Box\Mod\Hook\Api\Admin());
$data['event'] = 'testEvent';
$eventManager = Mockery::mock('\Box_EventManager');
/** @var Mockery\Expectation $expectation */
$expectation = $eventManager->shouldReceive('fire');
$expectation->atLeast()->once();
$expectation->andReturn(1);
$di = container();
$di['logger'] = new Tests\Helpers\TestLogger();
$di['events_manager'] = $eventManager;
$api->setDi($di);
$result = $api->call($data);
expect($result)->not->toBeEmpty();
});
test('call returns false when event param is missing', function (): void {
$api = apiEndpoint(new Box\Mod\Hook\Api\Admin());
$data['event'] = null;
$result = $api->call($data);
expect($result)->toBeBool();
expect($result)->toBeFalse();
});
test('batchConnect returns result', function (): void {
$api = apiEndpoint(new Box\Mod\Hook\Api\Admin());
$serviceMock = Mockery::mock(Box\Mod\Hook\Service::class);
/** @var Mockery\Expectation $expectation */
$expectation = $serviceMock->shouldReceive('batchConnect');
$expectation->atLeast()->once();
$expectation->andReturn(true);
$di = container();
$api->setDi($di);
$api->setService($serviceMock);
$result = $api->batch_connect([]);
expect($result)->not->toBeEmpty();
});
-265
View File
@@ -1,265 +0,0 @@
<?php
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
declare(strict_types=1);
use function Tests\Helpers\container;
use function Tests\Helpers\createEntity;
test('gets dependency injection container', function (): void {
$service = new Box\Mod\Hook\Service();
$di = container();
$service->setDi($di);
$getDi = $service->getDi();
expect($getDi)->toBe($di);
});
test('gets search query', function (): void {
$service = new Box\Mod\Hook\Service();
[$sql, $params] = $service->getSearchQuery([]);
expect($sql)->toBeString()
->and($params)->toBeArray()
->and(str_contains((string) $sql, 'SELECT id, rel_type, rel_id, meta_value as event, created_at, updated_at'))->toBeTrue()
->and($params)->toBe([]);
});
test('gets search query applies allowlisted sort', function (): void {
$service = new Box\Mod\Hook\Service();
[$sql] = $service->getSearchQuery([]);
expect($sql)->toContain('ORDER BY id ASC');
[$sortedSql] = $service->getSearchQuery(['sort' => 'event', 'direction' => 'desc']);
expect($sortedSql)->toContain('ORDER BY meta_value DESC, id DESC');
[$pkSql] = $service->getSearchQuery(['sort' => 'id', 'direction' => 'desc']);
expect($pkSql)->toContain('ORDER BY id DESC');
expect($pkSql)->not->toContain('id DESC, id DESC');
[$invalidSql] = $service->getSearchQuery(['sort' => 'rel_id']);
expect($invalidSql)->toContain('ORDER BY id ASC');
});
test('converts to api array', function (): void {
$service = new Box\Mod\Hook\Service();
$arrMock = ['testing' => 'okay'];
$result = $service->toApiArray($arrMock);
expect($result)->toBe($arrMock);
});
test('handles on after admin activate extension', function (): void {
$service = new Box\Mod\Hook\Service();
$eventParams = [
'id' => 1,
];
$eventMock = Mockery::mock('\Box_Event');
/** @var Mockery\Expectation $expectation1 */
$expectation1 = $eventMock->shouldReceive('getParameters');
$expectation1->atLeast()->once();
$expectation1->andReturn($eventParams);
/** @var Mockery\Expectation $expectation2 */
$expectation2 = $eventMock->shouldReceive('setReturnValue');
$expectation2->atLeast()->once();
$extension = createEntity(Box\Mod\Extension\Entity\Extension::class, ['id' => 1, 'type' => 'mod', 'name' => 'activity']);
$extensionRepository = Mockery::mock(Box\Mod\Extension\Repository\ExtensionRepository::class);
/** @var Mockery\Expectation $expectation3 */
$expectation3 = $extensionRepository->shouldReceive('find');
$expectation3->atLeast()->once();
$expectation3->andReturn($extension);
$hookService = Mockery::mock(Box\Mod\Hook\Service::class);
/** @var Mockery\Expectation $expectation4 */
$expectation4 = $hookService->shouldReceive('batchConnect');
$expectation4->atLeast()->once();
$expectation4->with('activity');
$di = container();
$di['em']->shouldReceive('getRepository')->with(Box\Mod\Extension\Entity\Extension::class)->andReturn($extensionRepository);
$di['mod_service'] = $di->protect(fn ($name): Mockery\MockInterface => $hookService);
/** @var Mockery\Expectation $expectation5 */
$expectation5 = $eventMock->shouldReceive('getDi');
$expectation5->atLeast()->once();
$expectation5->andReturn($di);
$service->setDi($di);
/* @var \Box_Event $eventMock */
$service->onAfterAdminActivateExtension($eventMock);
$result = true;
expect($result)->toBeTrue();
});
test('handles on after admin activate extension with missing id', function (): void {
$service = new Box\Mod\Hook\Service();
$eventParams = [];
$eventMock = Mockery::mock('\Box_Event');
/** @var Mockery\Expectation $expectation1 */
$expectation1 = $eventMock->shouldReceive('getParameters');
$expectation1->atLeast()->once();
$expectation1->andReturn($eventParams);
/** @var Mockery\Expectation $expectation2 */
$expectation2 = $eventMock->shouldReceive('setReturnValue');
$expectation2->atLeast()->once();
/* @var \Box_Event $eventMock */
$service->onAfterAdminActivateExtension($eventMock);
$result = false;
expect($result)->toBeFalse();
});
test('handles on after admin deactivate extension', function (): void {
$service = new Box\Mod\Hook\Service();
$eventParams = [
'type' => 'mod',
'id' => 1,
];
$eventMock = Mockery::mock('\Box_Event');
/** @var Mockery\Expectation $expectation1 */
$expectation1 = $eventMock->shouldReceive('getParameters');
$expectation1->atLeast()->once();
$expectation1->andReturn($eventParams);
/** @var Mockery\Expectation $expectation2 */
$expectation2 = $eventMock->shouldReceive('setReturnValue');
$expectation2->atLeast()->once();
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
/** @var Mockery\Expectation $expectation3 */
$expectation3 = $connection->shouldReceive('executeStatement');
$expectation3->atLeast()->once();
$di = container();
$di['em']->shouldReceive('getConnection')->andReturn($connection);
$eventsManager = Mockery::mock(Box_EventManager::class);
$eventsManager->shouldReceive('clearListenerCache')->once();
$di['events_manager'] = $eventsManager;
/** @var Mockery\Expectation $expectation4 */
$expectation4 = $eventMock->shouldReceive('getDi');
$expectation4->atLeast()->once();
$expectation4->andReturn($di);
$service->setDi($di);
/* @var \Box_Event $eventMock */
$service->onAfterAdminDeactivateExtension($eventMock);
$result = true;
expect($result)->toBeTrue();
});
test('batch connects', function (): void {
$service = new Box\Mod\Hook\Service();
$mod = 'activity';
$data['mods'] = [$mod];
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
$connection->shouldReceive('getDatabasePlatform')
->andReturn(Mockery::mock(Doctrine\DBAL\Platforms\MySQLPlatform::class));
$connection->shouldReceive('fetchOne')
->with(Mockery::on(fn ($sql): bool => str_contains((string) $sql, 'GET_LOCK')), Mockery::any())
->andReturn(1);
/** @var Mockery\Expectation $expectation1 */
$expectation1 = $connection->shouldReceive('fetchOne')
->with(Mockery::on(fn ($sql): bool => !str_contains((string) $sql, 'GET_LOCK')), Mockery::any());
$expectation1->atLeast()->once();
$expectation1->andReturn(false);
$connection->shouldReceive('executeStatement')
->byDefault();
$connection->shouldReceive('executeStatement')
->with(Mockery::on(fn ($sql): bool => str_contains((string) $sql, 'RELEASE_LOCK')), Mockery::any())
->once();
$connection->shouldReceive('transactional')
->atLeast()->once()
->andReturnUsing(fn (Closure $callback) => $callback($connection));
$returnArr = [
[
'id' => 2,
'rel_id' => 1,
'meta_value' => 'testValue',
],
];
/** @var Mockery\Expectation $expectation2 */
$expectation2 = $connection->shouldReceive('fetchAllAssociative');
$expectation2->atLeast()->once();
$expectation2->andReturn($returnArr);
$activityServiceMock = Mockery::mock(Box\Mod\Activity\Service::class);
$boxModMock = Mockery::mock(FOSSBilling\Module::class);
/** @var Mockery\Expectation $expectation3 */
$expectation3 = $boxModMock->shouldReceive('hasService');
$expectation3->atLeast()->once();
$expectation3->andReturn(true);
/** @var Mockery\Expectation $expectation4 */
$expectation4 = $boxModMock->shouldReceive('getService');
$expectation4->andReturn($activityServiceMock);
/** @var Mockery\Expectation $expectation5 */
$expectation5 = $boxModMock->shouldReceive('getName');
$expectation5->andReturn('activity');
$extensionServiceMock = Mockery::mock(Box\Mod\Extension\Service::class);
$extensionRepository = Mockery::mock(Box\Mod\Extension\Repository\ExtensionRepository::class);
$extensionRepository->shouldReceive('existsActiveByTypeAndName')
->byDefault()
->andReturn(true);
$di = container();
$di['em']->shouldReceive('getConnection')->andReturn($connection);
$di['em']->shouldReceive('getRepository')->with(Box\Mod\Extension\Entity\Extension::class)->andReturn($extensionRepository);
$di['em']->shouldReceive('persist')->atLeast()->once();
$di['em']->shouldReceive('flush')->atLeast()->once();
$di['mod'] = $di->protect(fn () => $boxModMock);
$di['mod_service'] = $di->protect(function ($name) use ($extensionServiceMock) {
if ($name == 'extension') {
return $extensionServiceMock;
}
});
$validatorMock = Mockery::mock(FOSSBilling\Validate::class);
/** @var Mockery\Expectation $validatorExpectation */
$validatorExpectation = $validatorMock->shouldReceive('checkRequiredParamsForArray');
$validatorExpectation->atLeast()->once();
$di['validator'] = $validatorMock;
$eventsManager = Mockery::mock(Box_EventManager::class);
$eventsManager->shouldReceive('clearListenerCache')->once();
$di['events_manager'] = $eventsManager;
$service->setDi($di);
$result = $service->batchConnect($mod);
expect($result)->toBeTrue();
});
test('batch connect reports failure when another process holds the lock', function (): void {
$service = new Box\Mod\Hook\Service();
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
$connection->shouldReceive('getDatabasePlatform')
->andReturn(Mockery::mock(Doctrine\DBAL\Platforms\MySQLPlatform::class));
$connection->shouldReceive('fetchOne')
->with(Mockery::on(fn ($sql): bool => str_contains((string) $sql, 'GET_LOCK')), Mockery::any())
->andReturn(0);
$connection->shouldNotReceive('transactional');
$connection->shouldNotReceive('fetchAllAssociative');
$connection->shouldNotReceive('executeStatement')
->with(Mockery::on(fn ($sql): bool => str_contains((string) $sql, 'RELEASE_LOCK')), Mockery::any());
$di = container();
$di['em']->shouldReceive('getConnection')->andReturn($connection);
$service->setDi($di);
$result = $service->batchConnect('activity');
expect($result)->toBeFalse();
});
+4 -6
View File
@@ -20,6 +20,7 @@ use Box\Mod\Invoice\Entity\PayGateway;
use Box\Mod\Invoice\Entity\Subscription;
use Box\Mod\Invoice\Entity\Tax;
use Box\Mod\Invoice\Entity\Transaction;
use Box\Mod\Invoice\Event\BeforeAdminTransactionProcessEvent;
use Box\Mod\Order\Entity\Order;
use FOSSBilling\InformationException;
use FOSSBilling\PaginationOptions;
@@ -467,10 +468,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
/**
* Calls due events on unpaid and approved invoices.
* Attach custom event hooks events:.
*
* onEventBeforeInvoiceIsDue - event receives params: id and days_left
* onEventAfterInvoiceIsDue - event receives params: id and days_passed
* Extensions can listen to BeforeInvoiceIsDueEvent and AfterInvoiceIsDueEvent.
*
* @optional bool $once_per_day - default true. Pass false if you want to execute this action more than once per day
*
@@ -485,7 +483,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
/**
* Send payment reminder notification for client.
* Calls event hook, so you can attach your custom notification code.
* Dispatches typed reminder events for custom notification code.
*
* @return bool
*/
@@ -538,7 +536,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
throw new \FOSSBilling\Exception('Transaction not found');
}
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminTransactionProcess', 'params' => ['id' => $model->getId()]]);
$this->getDi()['event_dispatcher']->dispatch(new BeforeAdminTransactionProcessEvent((int) $model->getId()));
$transactionService = $this->getDi()['mod_service']('Invoice', 'Transaction');
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after a renewal invoice has been generated for an order. */
final class AfterAdminGenerateRenewalInvoiceEvent extends Event
{
public function __construct(
public readonly int $orderId,
public readonly int $invoiceId,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an invoice has been approved and credit payment attempted. */
final class AfterAdminInvoiceApproveEvent extends Event
{
public function __construct(public readonly int $invoiceId)
{
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an order has been attached to an invoice. */
final class AfterAdminInvoiceAttachOrderEvent extends Event
{
public function __construct(
public readonly int $invoiceId,
public readonly int $orderId,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after a debit note has been issued for an invoice. */
final class AfterAdminInvoiceDebitEvent extends Event
{
public function __construct(
public readonly int $invoiceId,
public readonly int $debitNoteId,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an admin deletes an unpaid, unapproved invoice. */
final class AfterAdminInvoiceDeleteEvent extends Event
{
public function __construct(public readonly int $invoiceId)
{
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an invoice payment has committed, before item tasks run. */
final class AfterAdminInvoicePaymentReceivedEvent extends Event
{
public function __construct(public readonly int $invoiceId)
{
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an invoice refund attempt completes. */
final class AfterAdminInvoiceRefundEvent extends Event
{
public function __construct(public readonly int $invoiceId)
{
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an invoice has been reissued. */
final class AfterAdminInvoiceReissueEvent extends Event
{
public function __construct(
public readonly int $invoiceId,
public readonly int $replacementInvoiceId,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an invoice reminder has been recorded. */
final class AfterAdminInvoiceReminderRecordedEvent extends Event
{
public function __construct(public readonly int $invoiceId)
{
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after an invoice update has been persisted. */
final class AfterAdminInvoiceUpdateEvent extends Event
{
public function __construct(public readonly int $invoiceId)
{
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
final class AfterAdminSubscriptionCreateEvent extends Event
{
public function __construct(
public readonly int $subscriptionId,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
final class AfterAdminSubscriptionDeleteEvent extends Event
{
public function __construct(
public readonly int $subscriptionId,
) {
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after a transaction has been created and persisted. */
final class AfterAdminTransactionCreateEvent extends Event
{
public function __construct(
public readonly int $transactionId,
) {
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*/
namespace Box\Mod\Invoice\Event;
use FOSSBilling\Events\Event;
/** Dispatched after a transaction has been processed successfully. */
final class AfterAdminTransactionProcessEvent extends Event
{
public function __construct(
public readonly int $transactionId,
) {
}
}
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff. Show more