Merge pull request #60512 from nextcloud/backport/59966/stable34
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Integration sqlite / changes (push) Waiting to run
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, --tags ~@large files_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, capabilities_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, collaboration_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, comments_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, dav_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, federation_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, file_conversions) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, files_reminders) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, filesdrop_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, guests_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, ldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, openldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, openldap_numerical_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, remoteapi_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, routing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, setup_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, sharees_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, sharing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, theming_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (stable34, main, 8.4, stable34, videoverification_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite-summary (push) Blocked by required conditions
Psalm static code analysis / changes (push) Waiting to run
Psalm static code analysis / static-code-analysis (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-security (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-ocp (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-ncu (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-strict (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-summary (push) Blocked by required conditions

[stable34] Add runtime operations in WFE
This commit is contained in:
Benjamin Gaussorgues 2026-05-19 10:21:52 +02:00 committed by GitHub
commit 5b445c5cec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 619 additions and 9 deletions

View file

@ -41,6 +41,7 @@
<commands>
<command>OCA\WorkflowEngine\Command\Index</command>
<command>OCA\WorkflowEngine\Command\Runtime</command>
</commands>
<settings>

View file

@ -21,6 +21,7 @@ return array(
'OCA\\WorkflowEngine\\Check\\TFileCheck' => $baseDir . '/../lib/Check/TFileCheck.php',
'OCA\\WorkflowEngine\\Check\\UserGroupMembership' => $baseDir . '/../lib/Check/UserGroupMembership.php',
'OCA\\WorkflowEngine\\Command\\Index' => $baseDir . '/../lib/Command/Index.php',
'OCA\\WorkflowEngine\\Command\\Runtime' => $baseDir . '/../lib/Command/Runtime.php',
'OCA\\WorkflowEngine\\Controller\\AWorkflowOCSController' => $baseDir . '/../lib/Controller/AWorkflowOCSController.php',
'OCA\\WorkflowEngine\\Controller\\GlobalWorkflowsController' => $baseDir . '/../lib/Controller/GlobalWorkflowsController.php',
'OCA\\WorkflowEngine\\Controller\\RequestTimeController' => $baseDir . '/../lib/Controller/RequestTimeController.php',

View file

@ -36,6 +36,7 @@ class ComposerStaticInitWorkflowEngine
'OCA\\WorkflowEngine\\Check\\TFileCheck' => __DIR__ . '/..' . '/../lib/Check/TFileCheck.php',
'OCA\\WorkflowEngine\\Check\\UserGroupMembership' => __DIR__ . '/..' . '/../lib/Check/UserGroupMembership.php',
'OCA\\WorkflowEngine\\Command\\Index' => __DIR__ . '/..' . '/../lib/Command/Index.php',
'OCA\\WorkflowEngine\\Command\\Runtime' => __DIR__ . '/..' . '/../lib/Command/Runtime.php',
'OCA\\WorkflowEngine\\Controller\\AWorkflowOCSController' => __DIR__ . '/..' . '/../lib/Controller/AWorkflowOCSController.php',
'OCA\\WorkflowEngine\\Controller\\GlobalWorkflowsController' => __DIR__ . '/..' . '/../lib/Controller/GlobalWorkflowsController.php',
'OCA\\WorkflowEngine\\Controller\\RequestTimeController' => __DIR__ . '/..' . '/../lib/Controller/RequestTimeController.php',

View file

@ -42,15 +42,23 @@ class Application extends App implements IBootstrap {
#[\Override]
public function boot(IBootContext $context): void {
$context->injectFn(Closure::fromCallable([$this, 'registerRuntimeOperations']));
$context->injectFn(Closure::fromCallable([$this, 'registerRuleListeners']));
}
private function registerRuntimeOperations(Manager $manager): void {
$manager->reloadRuntimeOperations();
}
private function registerRuleListeners(IEventDispatcher $dispatcher,
ContainerInterface $container,
LoggerInterface $logger): void {
/** @var Manager $manager */
$manager = $container->get(Manager::class);
$configuredEvents = $manager->getAllConfiguredEvents();
$configuredEvents = array_merge_recursive(
$manager->getAllConfiguredEvents(),
$manager->getAllConfiguredRuntimeEvents(),
);
foreach ($configuredEvents as $operationClass => $events) {
foreach ($events as $entityClass => $eventNames) {

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\WorkflowEngine\Command;
use OC\Core\Command\Base;
use OC\User\NoUserException;
use OCA\WorkflowEngine\Helper\ScopeContext;
use OCA\WorkflowEngine\Manager;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\WorkflowEngine\IManager;
use Override;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Runtime extends Base {
public function __construct(
private Manager $manager,
private IUserManager $userManager,
private IUserSession $userSession,
) {
parent::__construct();
}
#[Override]
protected function configure() {
parent::configure();
$this
->setName('workflows:runtime:list')
->setDescription('Lists configured runtime workflows')
// need to add an optional filtering by app
->addArgument(
'appId',
InputArgument::OPTIONAL,
'Filter runtime workflows by appId',
null
)
->addArgument(
'scope',
InputArgument::OPTIONAL,
'Lists workflows for "admin", "user"',
'admin'
)
->addArgument(
'userId',
InputArgument::OPTIONAL,
'User ID used for user scope and session',
null
);
}
protected function mappedScope(string $scope): int {
return match($scope) {
'admin' => IManager::SCOPE_ADMIN,
'user' => IManager::SCOPE_USER,
default => -1,
};
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
$appId = $input->getArgument('appId');
$userId = $input->getArgument('userId');
if ($userId !== null) {
$user = $this->userManager->get($userId);
if ($user === null) {
throw new NoUserException("user $userId not found");
}
$this->userSession->setUser($user);
$this->manager->reloadRuntimeOperations();
}
$opsByClass = $this->manager->getAllRuntimeOperations(
new ScopeContext(
$this->mappedScope($input->getArgument('scope')),
$input->getArgument('userId')
),
$appId,
);
foreach ($opsByClass as &$operations) {
foreach ($operations as &$operation) {
$checks = $operation->checks;
$appId = $operation->appId;
$operation = $operation->toArray();
$operation['checks'] = $this->manager->getRuntimeChecks($checks, $appId);
}
unset($operation);
}
unset($operations);
$this->writeArrayInOutputFormat($input, $output, $opsByClass);
return 0;
}
}

View file

@ -6,6 +6,9 @@
*/
namespace OCA\WorkflowEngine;
use NCU\WorkflowEngine\Events\RegisterRuntimeOperationsEvent;
use NCU\WorkflowEngine\RuntimeOperation;
use NCU\WorkflowEngine\RuntimeScope;
use OCA\WorkflowEngine\Check\FileMimeType;
use OCA\WorkflowEngine\Check\FileName;
use OCA\WorkflowEngine\Check\FileSize;
@ -19,6 +22,7 @@ use OCA\WorkflowEngine\Entity\File;
use OCA\WorkflowEngine\Helper\ScopeContext;
use OCA\WorkflowEngine\Service\Logger;
use OCA\WorkflowEngine\Service\RuleMatcher;
use OCP\App\IAppManager;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Cache\CappedMemoryCache;
use OCP\DB\Exception;
@ -47,12 +51,29 @@ use Psr\Log\LoggerInterface;
* @psalm-import-type WorkflowEngineRule from ResponseDefinitions
*/
class Manager implements IManager {
/** @var array[] */
/** @var array<string, array<string, array<int, WorkflowEngineCheck>>> */
protected array $operations = [];
/** @var array<int, WorkflowEngineCheck> */
protected array $checks = [];
/** @var array<string, array<string, WorkflowEngineCheck>> */
protected array $registeredRuntimeChecks = [];
/**
* Registered runtime operations, keyed by app ID and runtime operation ID.
*
* @var array<string, array<string, RuntimeOperation>>
*/
protected array $registeredRuntimeOperations = [];
/**
* Registered runtime scopes, keyed by app ID and runtime operation ID.
*
* @var array<string, array<string, RuntimeScope>>
*/
protected array $registeredRuntimeScopes = [];
/** @var IEntity[] */
protected array $registeredEntities = [];
@ -77,6 +98,7 @@ class Manager implements IManager {
private readonly IEventDispatcher $dispatcher,
private readonly IAppConfig $appConfig,
private readonly ICacheFactory $cacheFactory,
private readonly IAppManager $appManager,
) {
$this->operationsByScope = new CappedMemoryCache(64);
}
@ -92,7 +114,7 @@ class Manager implements IManager {
);
}
public function getAllConfiguredEvents() {
public function getAllConfiguredEvents(): array {
$cache = $this->cacheFactory->createDistributed('flow');
$cached = $cache->get('events');
if ($cached !== null) {
@ -127,6 +149,30 @@ class Manager implements IManager {
return $operations;
}
/**
* Returns the events configured by runtime operations, in the same structure as getAllConfiguredEvents().
*
* @return array<class-string<IOperation>, array<class-string<IEntity>, list<string>>>
*/
public function getAllConfiguredRuntimeEvents(): array {
$eventsByOperationAndEntity = [];
foreach ($this->registeredRuntimeOperations as $appOperations) {
foreach ($appOperations as $operation) {
$operationClass = $operation->class;
$entityClass = $operation->entity;
$eventsByOperationAndEntity[$operationClass] ??= [];
$eventsByOperationAndEntity[$operationClass][$entityClass] ??= [];
/** @var list<string> $events */
$events = array_unique(
array_merge($eventsByOperationAndEntity[$operationClass][$entityClass], $operation->events)
);
$eventsByOperationAndEntity[$operationClass][$entityClass] = $events;
}
}
return $eventsByOperationAndEntity;
}
/**
* @param class-string<IOperation> $operationClass
* @return ScopeContext[]
@ -168,6 +214,33 @@ class Manager implements IManager {
return $this->scopesByOperation[$operationClass];
}
/**
* Gets configured scopes for operations registered at runtime.
*
* @param class-string<IOperation> $operationClass
* @return ScopeContext[]
*/
public function getAllConfiguredScopesForRuntimeOperation(string $operationClass): array {
$scopes = [];
foreach ($this->registeredRuntimeOperations as $appId => $appOperations) {
foreach ($appOperations as $operationId => $operation) {
if ($operation->class !== $operationClass) {
continue;
}
$runtimeScope = $this->registeredRuntimeScopes[$appId][$operationId] ?? null;
if ($runtimeScope === null) {
continue;
}
$scope = new ScopeContext($runtimeScope->type, $runtimeScope->value);
$scopes[$scope->getHash()] = $scope;
}
}
return $scopes;
}
public function getAllOperations(ScopeContext $scopeContext): array {
if (isset($this->operations[$scopeContext->getHash()])) {
return $this->operations[$scopeContext->getHash()];
@ -264,6 +337,115 @@ class Manager implements IManager {
return $query->getLastInsertId();
}
/**
* Get all operations registered at runtime
*
* @param ScopeContext $scopeContext
* @return array<class-string<IOperation>, list<RuntimeOperation>>
*/
public function getAllRuntimeOperations(ScopeContext $scopeContext, ?string $appFilter = null): array {
$result = [];
foreach ($this->registeredRuntimeOperations as $appId => $appOperations) {
if ($appFilter !== null && $appId !== $appFilter) {
continue;
}
foreach ($appOperations as $operationId => $operation) {
// scope stored per-app per-operation in registeredRuntimeScopes
$runtimeScope = $this->registeredRuntimeScopes[$appId][$operationId] ?? null;
if ($runtimeScope === null) {
continue;
}
// filter by provided $scopeContext
if ($runtimeScope->type !== $scopeContext->getScope()) {
continue;
}
if ($scopeContext->getScope() === IManager::SCOPE_USER && $runtimeScope->value !== $scopeContext->getScopeId()) {
continue;
}
$runtimeOperation = new RuntimeOperation($operationId,
$operation->class,
$operation->name,
$operation->checks,
$operation->operation,
$operation->entity,
$operation->events,
$appId,
);
$result[$operation->class][] = $runtimeOperation;
}
}
return $result;
}
/**
* Return operations registered at runtime, which are not persisted in the DB nor shown in the UI.
*
* @param class-string<IOperation> $class
* @param ScopeContext $scopeContext
* @return list<RuntimeOperation>
*/
public function getRuntimeOperations(string $class, ScopeContext $scopeContext): array {
$operations = $this->getAllRuntimeOperations($scopeContext);
return $operations[$class] ?? [];
}
/**
* @param string $appId
* @param class-string<IOperation> $class
* @param string $name
* @param list<WorkflowEngineCheck> $checks
* @param string $operation
* @param class-string<IEntity> $entity
* @param list<class-string<IEntityEvent>> $events
*/
public function addRuntimeOperation(
string $appId,
string $class,
string $name,
array $checks,
string $operation,
ScopeContext $scope,
string $entity,
array $events,
): void {
if (!$this->appManager->isEnabledForAnyone($appId)) {
throw new \InvalidArgumentException("App {$appId} is not enabled");
}
$this->validateOperation($class, $name, $checks, $operation, $scope, $entity, $events);
$checkHashes = [];
foreach ($checks as $check) {
$hash = md5($check['class'] . '::' . $check['operator'] . '::' . $check['value']);
$checkHashes[] = $hash;
$this->registeredRuntimeChecks[$appId] ??= [];
$this->registeredRuntimeChecks[$appId][$hash] ??= $check;
}
$operationId = uniqid($appId, true);
$runtimeOperation = new RuntimeOperation(
$operationId,
$class,
$name,
$checkHashes,
$operation,
$entity,
$events,
$appId,
);
$this->registeredRuntimeOperations[$appId] ??= [];
$this->registeredRuntimeOperations[$appId][$operationId] ??= $runtimeOperation;
$runtimeScope = new RuntimeScope($operationId, $scope->getScope(), $scope->getScopeId());
$this->registeredRuntimeScopes[$appId] ??= [];
$this->registeredRuntimeScopes[$appId][$operationId] ??= $runtimeScope;
}
/**
* @param string $class
* @param string $name
@ -523,6 +705,22 @@ class Manager implements IManager {
}
}
/**
* @param list<string> $checkHashes
* @param string $appId
* @return array<string, WorkflowEngineCheck> checks indexed by their ID
*/
public function getRuntimeChecks(array $checkHashes, string $appId): array {
$checks = [];
foreach ($checkHashes as $hash) {
if (!isset($this->registeredRuntimeChecks[$appId][$hash])) {
throw new \UnexpectedValueException("Runtime check {$hash} for app {$appId} missing");
}
$checks[$hash] = $this->registeredRuntimeChecks[$appId][$hash];
}
return $checks;
}
/**
* @param int[] $checkIds
* @return array<int, WorkflowEngineCheck>
@ -665,6 +863,14 @@ class Manager implements IManager {
$this->registeredOperators[get_class($operator)] = $operator;
}
public function reloadRuntimeOperations(): void {
$this->registeredRuntimeOperations = [];
$this->registeredRuntimeScopes = [];
$this->registeredRuntimeChecks = [];
$this->dispatcher->dispatchTyped(new RegisterRuntimeOperationsEvent($this));
}
#[\Override]
public function registerCheck(ICheck $check): void {
$this->registeredChecks[get_class($check)] = $check;

View file

@ -8,6 +8,7 @@ declare(strict_types=1);
*/
namespace OCA\WorkflowEngine\Service;
use NCU\WorkflowEngine\RuntimeOperation;
use OCA\WorkflowEngine\Helper\LogContext;
use OCA\WorkflowEngine\Helper\ScopeContext;
use OCA\WorkflowEngine\Manager;
@ -112,10 +113,12 @@ class RuleMatcher implements IRuleMatcher {
$operations = [];
foreach ($scopes as $scope) {
$operations = array_merge($operations, $this->manager->getOperations($class, $scope));
$operations = array_merge($operations, $this->manager->getRuntimeOperations($class, $scope));
}
if ($this->entity instanceof IEntity) {
$additionalScopes = $this->manager->getAllConfiguredScopesForOperation($class);
$additionalScopes = $this->manager->getAllConfiguredScopesForOperation($class)
+ $this->manager->getAllConfiguredScopesForRuntimeOperation($class);
foreach ($additionalScopes as $hash => $scopeCandidate) {
if ($scopeCandidate->getScope() !== IManager::SCOPE_USER || in_array($scopeCandidate, $scopes)) {
continue;
@ -128,20 +131,29 @@ class RuleMatcher implements IRuleMatcher {
->setOperation($this->operation);
$this->logger->logScopeExpansion($ctx);
$operations = array_merge($operations, $this->manager->getOperations($class, $scopeCandidate));
$operations = array_merge($operations, $this->manager->getRuntimeOperations($class, $scopeCandidate));
}
}
}
$matches = [];
foreach ($operations as $operation) {
$configuredEvents = json_decode($operation['events'], true);
if ($operation instanceof RuntimeOperation) {
$configuredEvents = $operation->events;
$checkIds = $operation->checks;
$checks = $this->manager->getRuntimeChecks($checkIds, $operation->appId);
// from now on, backwards compatibility is required
$operation = $operation->toArray();
} else {
$configuredEvents = json_decode($operation['events'], true);
$checkIds = json_decode($operation['checks'], true);
$checks = $this->manager->getChecks($checkIds);
}
if ($this->eventName !== null && !in_array($this->eventName, $configuredEvents)) {
continue;
}
$checkIds = json_decode($operation['checks'], true);
$checks = $this->manager->getChecks($checkIds);
foreach ($checks as $check) {
if (!$this->check($check)) {
// Check did not match, continue with the next operation

View file

@ -10,6 +10,7 @@ use OC\Files\Config\UserMountCache;
use OCA\WorkflowEngine\Entity\File;
use OCA\WorkflowEngine\Helper\ScopeContext;
use OCA\WorkflowEngine\Manager;
use OCP\App\IAppManager;
use OCP\AppFramework\QueryException;
use OCP\AppFramework\Services\IAppConfig;
use OCP\EventDispatcher\Event;
@ -33,6 +34,7 @@ use OCP\WorkflowEngine\IEntityEvent;
use OCP\WorkflowEngine\IManager;
use OCP\WorkflowEngine\IOperation;
use OCP\WorkflowEngine\IRuleMatcher;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
@ -84,6 +86,7 @@ class ManagerTest extends TestCase {
protected IEventDispatcher&MockObject $dispatcher;
protected IAppConfig&MockObject $config;
protected ICacheFactory&MockObject $cacheFactory;
protected IAppManager&MockObject $appManager;
protected function setUp(): void {
parent::setUp();
@ -101,6 +104,7 @@ class ManagerTest extends TestCase {
$this->dispatcher = $this->createMock(IEventDispatcher::class);
$this->config = $this->createMock(IAppConfig::class);
$this->cacheFactory = $this->createMock(ICacheFactory::class);
$this->appManager = $this->createMock(IAppManager::class);
$this->manager = new Manager(
$this->db,
@ -110,7 +114,8 @@ class ManagerTest extends TestCase {
$this->session,
$this->dispatcher,
$this->config,
$this->cacheFactory
$this->cacheFactory,
$this->appManager,
);
$this->clearTables();
}
@ -737,6 +742,125 @@ class ManagerTest extends TestCase {
}
}
private function prepareContainerForRuntimeOperation(): void {
$operationMock = $this->createMock(IOperation::class);
$operationMock->method('isAvailableForScope')->willReturn(true);
$myEventMock = $this->createMock(IEntityEvent::class);
$myEventMock->method('getEventName')->willReturn('MyEvent');
$otherEventMock = $this->createMock(IEntityEvent::class);
$otherEventMock->method('getEventName')->willReturn('OtherEvent');
$entityMock = $this->createMock(IEntity::class);
$entityMock->method('getEvents')->willReturn([$myEventMock, $otherEventMock]);
$checkMock = $this->createMock(ICheck::class);
$checkMock->method('supportedEntities')->willReturn([]);
$this->container->expects($this->any())
->method('get')
->willReturnCallback(fn ($class) => match ($class) {
IOperation::class => $operationMock,
IEntity::class => $entityMock,
ICheck::class => $checkMock,
default => $this->createMock($class),
});
}
public function testAddRuntimeOperationRejectsUnknownApp(): void {
$this->appManager->method('isEnabledForAnyone')->willReturn(false);
$this->expectException(\InvalidArgumentException::class);
$scope = $this->buildScope();
$check = ['class' => ICheck::class, 'operator' => 'is', 'value' => 'test'];
$this->manager->addRuntimeOperation('unknownapp', IOperation::class, 'Test', [$check], '', $scope, IEntity::class, ['MyEvent']);
}
public function testGetAllConfiguredRuntimeEventsEmpty(): void {
$this->assertSame([], $this->manager->getAllConfiguredRuntimeEvents());
}
public function testGetAllConfiguredRuntimeEvents(): void {
$this->appManager->method('isEnabledForAnyone')->willReturn(true);
$this->prepareContainerForRuntimeOperation();
$scope = $this->buildScope();
$check = ['class' => ICheck::class, 'operator' => 'is', 'value' => 'test'];
$this->manager->addRuntimeOperation('testapp', IOperation::class, 'Op1', [$check], '', $scope, IEntity::class, ['MyEvent']);
$this->manager->addRuntimeOperation('testapp', IOperation::class, 'Op2', [$check], '', $scope, IEntity::class, ['MyEvent', 'OtherEvent']);
$events = $this->manager->getAllConfiguredRuntimeEvents();
$this->assertArrayHasKey(IOperation::class, $events);
$this->assertArrayHasKey(IEntity::class, $events[IOperation::class]);
$eventNames = $events[IOperation::class][IEntity::class];
$this->assertContains('MyEvent', $eventNames);
$this->assertContains('OtherEvent', $eventNames);
$this->assertCount(2, $eventNames);
}
public function testRuntimeOperationScopeIsolation(): void {
$this->appManager->method('isEnabledForAnyone')->willReturn(true);
$this->prepareContainerForRuntimeOperation();
$adminScope = $this->buildScope();
$userScope = $this->buildScope('alice');
$check = ['class' => ICheck::class, 'operator' => 'is', 'value' => 'test'];
$this->manager->addRuntimeOperation('testapp', IOperation::class, 'AdminOp', [$check], '', $adminScope, IEntity::class, ['MyEvent']);
$this->manager->addRuntimeOperation('testapp', IOperation::class, 'UserOp', [$check], '', $userScope, IEntity::class, ['MyEvent']);
$adminOps = $this->manager->getRuntimeOperations(IOperation::class, $adminScope);
$userOps = $this->manager->getRuntimeOperations(IOperation::class, $userScope);
$this->assertCount(1, $adminOps);
$this->assertSame('AdminOp', $adminOps[0]->name);
$this->assertCount(1, $userOps);
$this->assertSame('UserOp', $userOps[0]->name);
$scopes = $this->manager->getAllConfiguredScopesForRuntimeOperation(IOperation::class);
$this->assertCount(2, $scopes);
$scopeTypes = array_map(fn ($s) => $s->getScope(), array_values($scopes));
$this->assertContains(IManager::SCOPE_ADMIN, $scopeTypes);
$this->assertContains(IManager::SCOPE_USER, $scopeTypes);
}
public static function dataGetRuntimeChecks(): array {
return [
'single operation' => [1],
'two operations with same check are deduplicated' => [2],
];
}
#[DataProvider(methodName: 'dataGetRuntimeChecks')]
public function testGetRuntimeChecks(int $opCount): void {
$this->appManager->method('isEnabledForAnyone')->willReturn(true);
$this->prepareContainerForRuntimeOperation();
$scope = $this->buildScope();
$check = ['class' => ICheck::class, 'operator' => 'is', 'value' => 'testvalue'];
for ($i = 0; $i < $opCount; $i++) {
$this->manager->addRuntimeOperation('myapp', IOperation::class, "Op{$i}", [$check], '', $scope, IEntity::class, ['MyEvent']);
}
$ops = $this->manager->getRuntimeOperations(IOperation::class, $scope);
$this->assertCount($opCount, $ops);
$hash = md5(ICheck::class . '::is::testvalue');
$resolved = $this->manager->getRuntimeChecks([$hash], 'myapp');
$this->assertCount(1, $resolved);
$this->assertArrayHasKey($hash, $resolved);
$this->assertSame(ICheck::class, $resolved[$hash]['class']);
$this->assertSame('is', $resolved[$hash]['operator']);
$this->assertSame('testvalue', $resolved[$hash]['value']);
}
public function testGetRuntimeChecksThrowsForUnknownHash(): void {
$this->expectException(\UnexpectedValueException::class);
$this->manager->getRuntimeChecks(['unknownhash'], 'myapp');
}
public function testValidateOperationScopeNotAvailable(): void {
$check = [
'id' => 1,

View file

@ -37,6 +37,9 @@ return array(
'NCU\\Security\\Signature\\ISignatureManager' => $baseDir . '/lib/unstable/Security/Signature/ISignatureManager.php',
'NCU\\Security\\Signature\\ISignedRequest' => $baseDir . '/lib/unstable/Security/Signature/ISignedRequest.php',
'NCU\\Security\\Signature\\Model\\Signatory' => $baseDir . '/lib/unstable/Security/Signature/Model/Signatory.php',
'NCU\\WorkflowEngine\\Events\\RegisterRuntimeOperationsEvent' => $baseDir . '/lib/unstable/WorkflowEngine/Events/RegisterRuntimeOperationsEvent.php',
'NCU\\WorkflowEngine\\RuntimeOperation' => $baseDir . '/lib/unstable/WorkflowEngine/RuntimeOperation.php',
'NCU\\WorkflowEngine\\RuntimeScope' => $baseDir . '/lib/unstable/WorkflowEngine/RuntimeScope.php',
'OCP\\Accounts\\IAccount' => $baseDir . '/lib/public/Accounts/IAccount.php',
'OCP\\Accounts\\IAccountManager' => $baseDir . '/lib/public/Accounts/IAccountManager.php',
'OCP\\Accounts\\IAccountProperty' => $baseDir . '/lib/public/Accounts/IAccountProperty.php',

View file

@ -78,6 +78,9 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'NCU\\Security\\Signature\\ISignatureManager' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/ISignatureManager.php',
'NCU\\Security\\Signature\\ISignedRequest' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/ISignedRequest.php',
'NCU\\Security\\Signature\\Model\\Signatory' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Model/Signatory.php',
'NCU\\WorkflowEngine\\Events\\RegisterRuntimeOperationsEvent' => __DIR__ . '/../../..' . '/lib/unstable/WorkflowEngine/Events/RegisterRuntimeOperationsEvent.php',
'NCU\\WorkflowEngine\\RuntimeOperation' => __DIR__ . '/../../..' . '/lib/unstable/WorkflowEngine/RuntimeOperation.php',
'NCU\\WorkflowEngine\\RuntimeScope' => __DIR__ . '/../../..' . '/lib/unstable/WorkflowEngine/RuntimeScope.php',
'OCP\\Accounts\\IAccount' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccount.php',
'OCP\\Accounts\\IAccountManager' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccountManager.php',
'OCP\\Accounts\\IAccountProperty' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccountProperty.php',

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\WorkflowEngine\Events;
use OCP\EventDispatcher\Event;
use OCP\WorkflowEngine\IManager;
/**
* @experimental 34.0.0
*/
final class RegisterRuntimeOperationsEvent extends Event {
/**
* @experimental 34.0.0
*/
public function __construct(
private readonly IManager $manager,
) {
parent::__construct();
}
/**
* @experimental 34.0.0
*/
public function getManager(): IManager {
return $this->manager;
}
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\WorkflowEngine;
use OCP\WorkflowEngine\IEntity;
use OCP\WorkflowEngine\IOperation;
/**
* @experimental 34.0.0
*/
final readonly class RuntimeOperation {
private bool $runtime;
/**
* @experimental 34.0.0
*
* @param string $id
* @param class-string<IOperation> $class
* @param string $name
* @param list<string> $checks
* @param string $operation
* @param class-string<IEntity> $entity
* @param list<string> $events
* @param string $appId
*/
public function __construct(
public string $id,
public string $class,
public string $name,
public array $checks,
public string $operation,
public string $entity,
public array $events,
public string $appId,
) {
$this->runtime = true;
}
/**
* @experimental 34.0.0
* @return array<key-of<properties-of<self>>, value-of<properties-of<self>>>
*/
public function toArray(): array {
return [
'id' => $this->id,
'class' => $this->class,
'name' => $this->name,
'checks' => $this->checks,
'operation' => $this->operation,
'entity' => $this->entity,
'events' => $this->events,
'appId' => $this->appId,
'runtime' => $this->runtime,
];
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace NCU\WorkflowEngine;
/**
* @experimental 34.0.0
*/
final readonly class RuntimeScope {
/**
* @experimental 34.0.0
*
* @param string $operationId
* @param int $type
* @param string $value
*/
public function __construct(
public string $operationId,
public int $type,
public string $value,
) {
}
/**
* @experimental 34.0.0
* @return array<key-of<properties-of<self>>, value-of<properties-of<self>>>
*/
public function toArray(): array {
return [
'operationId' => $this->operationId,
'type' => $this->type,
'value' => $this->value,
];
}
}

View file

@ -19,4 +19,7 @@
<projectFiles>
<directory name="lib/unstable"/>
</projectFiles>
<extraFiles>
<directory name="3rdparty"/>
</extraFiles>
</psalm>