From 59794316c661d27e0bad484ef9a43fafe5138d7e Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:39:13 +0200 Subject: [PATCH 01/37] =?UTF-8?q?fix(review):=20#4580=20eng-f2=20=E2=80=94?= =?UTF-8?q?=20A=20required=20file=20property=20can=20never=20be=20satisfie?= =?UTF-8?q?d=20by=20an=20upload=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateAll() now excuses a property that carries an upload in the plan's files ('uploaded'), because mapPayload() moves the file out of the validated object. A required file property with a valid token is accepted; without a token it still answers 422 required. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660431 Sweep: saveDraft/promoteDraft/submit all go through validateAll, so one place. DraftActivation does not see form uploads. Check: phpunit tests/Unit/Service/Form/FormSubmitServiceTest.php -> OK 26 tests (new test errors without the fix); phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Form/FormSubmitService.php | 6 ++++ .../Service/Form/FormSubmitServiceTest.php | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index a7f876b264..1b86410575 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -648,6 +648,12 @@ private function validateAll(array $plans, string $mode = self::MODE_FULL): void $excused = array_merge($policy->requiredExcusals(schema: $plan['schema']), $excused); } + // An upload token moved its file out of the object (mapPayload), so the + // property is absent from the validated object although it was supplied. + foreach (array_keys($plan['files']) as $uploaded) { + $excused[(string)$uploaded] = 'uploaded'; + } + try { $result = $this->validator->validateObject( object: $object, diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php index 863e05eb30..e4058b15c8 100644 --- a/tests/Unit/Service/Form/FormSubmitServiceTest.php +++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php @@ -589,6 +589,36 @@ public function testAnUploadTokenIsClaimedOnlyOnSuccess(): void { $this->uploads->materialise(formId: 'form-1', token: $token); }//end testAnUploadTokenIsClaimedOnlyOnSuccess() + /** + * A required file property is satisfied by its upload token, and still `required` without one. + */ + public function testARequiredFilePropertyIsSatisfiedByAnUploadToken(): void { + $case = $this->schemaBySlug['case']; + $case->setRequired(['title', 'caseType', 'identifier', 'bijlage']); + $path = (string)tempnam(sys_get_temp_dir(), 'or-submit-upload'); + file_put_contents($path, '%PDF-1.7'); + $this->temporary[] = $path; + $token = $this->uploads->issue( + formId: 'form-1', + property: 'bijlage', + rule: $case->getProperties()['bijlage'], + file: ['name' => 'a.pdf', 'type' => 'application/pdf', 'tmp_name' => $path, 'error' => UPLOAD_ERR_OK, 'size' => 8] + )['token']; + $mapping = ['fields' => [['field' => 'onderwerp', 'property' => 'title'], ['field' => 'bewijs', 'property' => 'bijlage']], 'fixed' => ['caseType' => 'ct']]; + $destination = ['register' => 'dossiq', 'schema' => 'case']; + + try { + $this->service->submit(destination: $destination, mapping: $mapping, payload: ['onderwerp' => 'a'], scope: 'form-1'); + $this->fail('A required file property without an upload was accepted.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(422, $refused->getStatus()); + $this->assertContains('bijlage', array_map(static fn (array $finding): string => (string)($finding['property'] ?? ''), $refused->getFindings())); + } + + $this->service->submit(destination: $destination, mapping: $mapping, payload: ['onderwerp' => 'a', 'bewijs' => ['uploadToken' => $token]], scope: 'form-1'); + $this->assertSame('a.pdf', $this->saves[0]['files']['bijlage']['name']); + }//end testARequiredFilePropertyIsSatisfiedByAnUploadToken() + /** * A token issued for another property is refused, not attached elsewhere. */ From 402a14a9a92385feceb56b798aa9d59896402bb9 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:40:05 +0200 Subject: [PATCH 02/37] =?UTF-8?q?fix(review):=20#4580=20eng-f6=20=E2=80=94?= =?UTF-8?q?=20Review=20policy=20fails=20open=20for=20visibility=20when=20i?= =?UTF-8?q?t=20cannot=20be=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unreadable schema now yields a decider-owned policy that also hides others, and a throwing file-to-object lookup in subjectOf() yields the same closed policy instead of the open instance default (also covers the surface slice's duplicate of this finding). Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660484 Sweep: both failure branches of ReviewPolicyResolver now share closedPolicy(); no other resolver builds a fail-closed review policy. Check: phpunit tests/Unit/Service/Decision -> OK 17 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Decision/ReviewPolicyResolver.php | 34 +++++++++++++++---- .../Decision/ReviewPolicyResolverTest.php | 28 +++++++++++++-- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/lib/Service/Decision/ReviewPolicyResolver.php b/lib/Service/Decision/ReviewPolicyResolver.php index 5fb5b2ee37..ffd7577b91 100644 --- a/lib/Service/Decision/ReviewPolicyResolver.php +++ b/lib/Service/Decision/ReviewPolicyResolver.php @@ -79,8 +79,17 @@ public function __construct( */ public function forRelation(EntityRelation $relation): array { $subject = $this->subjectOf(relation: $relation); + $lookupFailed = (($subject['lookupFailed'] ?? false) === true); + unset($subject['lookupFailed']); $schema = (string)($subject['schema'] ?? ''); + if ($lookupFailed === true) { + // Fail closed: the file's object could not be found, so the schema + // that may hide or restrict the decision is unknown. Never fall + // to the instance default, which is open when unset. + return ['policy' => $this->closedPolicy(), 'subject' => $subject]; + } + if ($schema === '') { return ['policy' => $this->instanceDefault(), 'subject' => $subject]; } @@ -89,15 +98,13 @@ public function forRelation(EntityRelation $relation): array { $configuration = ($this->schemaMapper->find(id: $schema, _rbac: false, _multitenancy: false)->getConfiguration() ?? []); } catch (Throwable $error) { // Fail closed: a policy that cannot be read is decider-owned with - // no supervisors, so only the decider and an administrator may. + // no supervisors and hides the decision from other reviewers, so + // only the decider and an administrator may see or change it. $this->logger->warning( message: '[ReviewPolicyResolver] The schema could not be read; the decision stays with its decider.', context: ['schema' => $schema, 'error' => $error->getMessage()] ); - return [ - 'policy' => ReviewPolicy::fromDeclaration(declaration: ['ownership' => ReviewPolicy::OWNERSHIP_DECIDER]), - 'subject' => $subject, - ]; + return ['policy' => $this->closedPolicy(), 'subject' => $subject]; } if (array_key_exists(ReviewPolicy::ANNOTATION, $configuration) === false) { @@ -113,7 +120,8 @@ public function forRelation(EntityRelation $relation): array { * @param EntityRelation $relation The relation. * * @return array subjectType, subjectId, fileId, objectId, - * register, schema and objectUuid. + * register, schema and objectUuid; `lookupFailed` + * (true) when the file-to-object lookup threw. */ private function subjectOf(EntityRelation $relation): array { $subject = [ @@ -139,9 +147,10 @@ private function subjectOf(EntityRelation $relation): array { } } catch (Throwable $error) { $this->logger->info( - message: '[ReviewPolicyResolver] The file\'s object could not be found; the instance default applies.', + message: '[ReviewPolicyResolver] The file\'s object could not be found; the decision stays with its decider.', context: ['fileId' => $fileId, 'error' => $error->getMessage()] ); + $subject['lookupFailed'] = true; return $subject; } @@ -154,6 +163,17 @@ private function subjectOf(EntityRelation $relation): array { return $subject; }//end subjectOf() + /** + * The policy for a declaration that cannot be read: decider-owned, no supervisors, others hidden. + * + * @return ReviewPolicy The policy. + * + * @SuppressWarnings(PHPMD.StaticAccess) ReviewPolicy::fromDeclaration is its named constructor. + */ + private function closedPolicy(): ReviewPolicy { + return ReviewPolicy::fromDeclaration(declaration: ['ownership' => ReviewPolicy::OWNERSHIP_DECIDER, 'hideOthers' => true]); + }//end closedPolicy() + /** * The instance default, for files that belong to no schema. Unset is open. * diff --git a/tests/Unit/Service/Decision/ReviewPolicyResolverTest.php b/tests/Unit/Service/Decision/ReviewPolicyResolverTest.php index 5937206ee0..d024fd7e0c 100644 --- a/tests/Unit/Service/Decision/ReviewPolicyResolverTest.php +++ b/tests/Unit/Service/Decision/ReviewPolicyResolverTest.php @@ -40,10 +40,11 @@ class ReviewPolicyResolverTest extends TestCase { * * @param array|null $schemaConfiguration The schema's configuration, or null when it cannot be read. * @param array $fileSettings The stored file settings. + * @param bool $lookupFails Whether the file-to-object lookup throws. * * @return ReviewPolicyResolver The resolver. */ - private function resolver(?array $schemaConfiguration, array $fileSettings=[]): ReviewPolicyResolver { + private function resolver(?array $schemaConfiguration, array $fileSettings=[], bool $lookupFails=false): ReviewPolicyResolver { $schemas = $this->createMock(SchemaMapper::class); if ($schemaConfiguration === null) { $schemas->method('find')->willThrowException(new RuntimeException('gone')); @@ -65,7 +66,11 @@ private function resolver(?array $schemaConfiguration, array $fileSettings=[]): $file = $this->createMock(File::class); $root = $this->createMock(IRootFolder::class); - $root->method('getFirstNodeById')->willReturnCallback(static fn (int $id) => ($id === 99 ? $file : null)); + if ($lookupFails === true) { + $root->method('getFirstNodeById')->willThrowException(new RuntimeException('lookup failed')); + } else { + $root->method('getFirstNodeById')->willReturnCallback(static fn (int $id) => ($id === 99 ? $file : null)); + } $object = new ObjectEntity(); $object->setRegister('3'); @@ -137,5 +142,24 @@ public function testAnUnreadableSchemaFailsClosed(): void { self::assertTrue($policy->isDeciderOwned()); self::assertSame([], $policy->getSupervisors()); + self::assertTrue($policy->hidesOthers()); }//end testAnUnreadableSchemaFailsClosed() + + /** + * A file whose object cannot be looked up is decider-owned and hidden, not the open instance default. + * + * @return void + */ + public function testAFailedFileLookupFailsClosed(): void { + $relation = new EntityRelation(); + $relation->setId(7); + $relation->setFileId(99); + + $resolved = $this->resolver(schemaConfiguration: [], lookupFails: true)->forRelation(relation: $relation); + + self::assertTrue($resolved['policy']->isDeciderOwned()); + self::assertTrue($resolved['policy']->hidesOthers()); + self::assertSame([], $resolved['policy']->getSupervisors()); + self::assertArrayNotHasKey('lookupFailed', $resolved['subject']); + }//end testAFailedFileLookupFailsClosed() }//end class From 728c921b61da89abbfa5088827ebe5780c218f22 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:40:48 +0200 Subject: [PATCH 03/37] =?UTF-8?q?fix(review):=20#4580=20data-f1=20?= =?UTF-8?q?=E2=80=94=20Bulk=20save=20overwrites=20another=20user's=20draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyBulkSafeguards() now asks DraftStatusPolicy::keptFrom() for an existing row when RBAC is enforced and rejects the row with the same 'Permission denied' reason a refused update gives, so a draft stays unannounced. The owner's bulk save still updates it. Tests in Wave12BulkSafeguardsTest (the test fails without the fix). Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660284 Sweep: keptFrom had one caller (ObjectService::refuseSomebodyElsesDraft); the bulk safeguard is the only other path that resolves an existing row past RBAC. Check: phpunit Wave12BulkSafeguardsTest + BulkSafeguardSchemaResolutionTest OK; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Object/SaveObjects.php | 22 +++++++ .../Object/Wave12BulkSafeguardsTest.php | 64 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/lib/Service/Object/SaveObjects.php b/lib/Service/Object/SaveObjects.php index f2c8d77968..2c582d3aae 100644 --- a/lib/Service/Object/SaveObjects.php +++ b/lib/Service/Object/SaveObjects.php @@ -813,6 +813,28 @@ private function applyBulkSafeguards( continue; } + // Step 2b: a draft is kept to its owner. The existing row was found + // past the read RBAC, so without this a user who cannot read another + // user's draft could overwrite it by sending its uuid. The answer is + // the one a refused permission gives, so the draft's existence stays + // unannounced (same rule as ObjectService::refuseSomebodyElsesDraft). + if ($effectiveRbac === true && $existingEntity !== null) { + $draftKept = (new DraftStatusPolicy())->keptFrom( + object: $existingEntity, + schema: $rowSchema, + userId: $currentUser?->getUID() + ); + if ($draftKept === true) { + $this->recordSafeguardRejection( + object: $sanitised, + reason: 'Permission denied for action ' . $action . ' on schema ' . $rowSchema->getSlug(), + result: $result, + index: $rowIndex + ); + continue; + } + } + // Step 3: PermissionHandler check (per row). if ($effectiveRbac === true) { $hasPermission = $this->permissionHandler->hasPermission( diff --git a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php index 3e6f93ae65..5f274f4d2a 100644 --- a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php +++ b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php @@ -326,6 +326,70 @@ public function testBulkUpdateRowAgainstAppendOnlySchemaIsRejected(): void { $this->assertStringContainsString('appendOnly', $result['invalid'][0]['error']); }//end testBulkUpdateRowAgainstAppendOnlySchemaIsRejected() + // === draft ownership === + + /** + * Bulk save of the uuid of somebody else's draft (schema without draftsVisible) is rejected as a denied permission. + */ + public function testBulkSaveOverSomebodyElsesDraftIsRejected(): void { + $this->mockUser('bob', isAdmin: false); + $schema = $this->newSchema(1); + $register = $this->newRegister(10); + // The permission handler would allow it: the hole is that it has no draft rule. + $this->permissionHandler->method('hasPermission')->willReturn(true); + $this->storeDraft(owner: 'alice'); + + $result = $this->initResult(1); + $passed = $this->callApplyBulkSafeguards( + objects: [['@self' => ['uuid' => '22222222-2222-2222-2222-222222222222'], 'title' => 'overwritten']], + register: $register, + schema: $schema, + _rbac: true, + _validation: false, + result: $result + ); + + $this->assertSame([], $passed); + $this->assertSame(1, $result['statistics']['invalid']); + $this->assertStringContainsString('Permission denied', $result['invalid'][0]['error']); + }//end testBulkSaveOverSomebodyElsesDraftIsRejected() + + /** + * The owner's own bulk save of their draft still goes through. + */ + public function testBulkSaveOverYourOwnDraftPasses(): void { + $this->mockUser('alice', isAdmin: false); + $schema = $this->newSchema(1); + $register = $this->newRegister(10); + $this->permissionHandler->method('hasPermission')->willReturn(true); + $this->storeDraft(owner: 'alice'); + + $result = $this->initResult(1); + $passed = $this->callApplyBulkSafeguards( + objects: [['@self' => ['uuid' => '22222222-2222-2222-2222-222222222222'], 'title' => 'mine']], + register: $register, + schema: $schema, + _rbac: true, + _validation: false, + result: $result + ); + + $this->assertCount(1, $passed); + $this->assertSame(0, $result['statistics']['invalid']); + }//end testBulkSaveOverYourOwnDraftPasses() + + /** + * Store a draft of the given owner as the existing row of uuid 2222…. + */ + private function storeDraft(string $owner): void { + $draft = new \OCA\OpenRegister\Db\ObjectEntity(); + $draft->setUuid('22222222-2222-2222-2222-222222222222'); + $draft->setStatus('draft'); + $draft->setOwner($owner); + $this->objectMapper->method('find')->willReturn($draft); + $this->objectMapper->method('findObjectsByUuidsInRegisterSchema')->willReturn(['22222222-2222-2222-2222-222222222222' => $draft]); + }//end storeDraft() + public function testBulkCreateAgainstAppendOnlySchemaIsAllowed(): void { // appendOnly blocks UPDATE only; INSERT must still succeed. $this->mockUser('alice', isAdmin: false); From f67de4564acee13f02a2b5e2f443bee818a2dbf2 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:42:20 +0200 Subject: [PATCH 04/37] =?UTF-8?q?fix(review):=20#4580=20plat-f1=20?= =?UTF-8?q?=E2=80=94=20Frozen-file=20guard=20ignores=20writes=20made=20thr?= =?UTF-8?q?ough=20a=20share?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node outside the openregister home (a sharee's view: /alice/files//x.pdf) is now resolved by file id: only a node owned by the app account is looked up, in the app's home via IRootFolder::getById, and the existing UUID-ancestor + folder proof runs on that twin. A node that does not exist yet stands for its nearest existing ancestor. A home that cannot be searched fails closed with a log line. New constructor argument: IRootFolder (autowired). Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660603 Sweep: the listener is the only path-prefix owner test (grep for '/files/' prefix checks in lib/ finds no other frozen-file gate). Check: phpunit tests/Unit/Listener/FrozenNodeWriteListenerTest.php OK 21 tests; phpcs on the lib file clean. The Newman PUT-as-sharee case from Done-when was not run (no live Nextcloud). Co-Authored-By: Claude Opus 5.5 --- lib/Listener/FrozenNodeWriteListener.php | 107 ++++++++++++++++-- .../Listener/FrozenNodeWriteListenerTest.php | 102 ++++++++++++++++- 2 files changed, 197 insertions(+), 12 deletions(-) diff --git a/lib/Listener/FrozenNodeWriteListener.php b/lib/Listener/FrozenNodeWriteListener.php index 6546168726..ecf2034819 100644 --- a/lib/Listener/FrozenNodeWriteListener.php +++ b/lib/Listener/FrozenNodeWriteListener.php @@ -36,6 +36,7 @@ use OCP\Files\Events\Node\BeforeNodeDeletedEvent; use OCP\Files\Events\Node\BeforeNodeRenamedEvent; use OCP\Files\Events\Node\BeforeNodeWrittenEvent; +use OCP\Files\IRootFolder; use OCP\Files\Node; use OCP\HintException; use Psr\Log\LoggerInterface; @@ -50,9 +51,14 @@ * and those never pass the controller. This listener is that door. * * How a node finds its object: object folders live in the `openregister` - * account's home and are named by the object's UUID. A node outside that - * home is ignored at once (one string compare, so the rest of the instance - * pays nothing). Inside it, the nearest ancestor named like a UUID is the + * account's home and are named by the object's UUID. A node in that home is + * checked by its path. A node reached through a share (the way a person gets + * at an object's files) has the path of the sharee's own tree, so it is + * resolved by file id instead: a node whose owner is not the `openregister` + * account is ignored at once (one owner compare, so the rest of the instance + * pays nothing); one that is owned by it is looked up by id in the account's + * home, and a node that does not exist yet stands for its nearest existing + * ancestor. In the home, the nearest ancestor named like a UUID is the * candidate owner, and it counts only when that object's own `folder` is * that ancestor's id, the same proof `FolderManagementHandler::getObjectForFile()` * asks for. @@ -89,14 +95,16 @@ class FrozenNodeWriteListener implements IEventListener { /** * Constructor. * - * @param FileWriteGuard $guard Decides whether the owning object refuses file writes. - * @param MagicMapper $objects Resolves the owning object by its UUID. - * @param LoggerInterface $logger Records a refusal on an unresolvable owner. + * @param FileWriteGuard $guard Decides whether the owning object refuses file writes. + * @param MagicMapper $objects Resolves the owning object by its UUID. + * @param LoggerInterface $logger Records a refusal on an unresolvable owner. + * @param IRootFolder $rootFolder Finds a shared node's twin in the `openregister` home by file id. */ public function __construct( private readonly FileWriteGuard $guard, private readonly MagicMapper $objects, private readonly LoggerInterface $logger, + private readonly IRootFolder $rootFolder, ) { }//end __construct() @@ -169,11 +177,31 @@ private function refusalFor(Node $node): ?ObjectStateWriteException { } $home = '/' . FileOwnershipHandler::APP_USER . '/files/'; + $anchor = $node; + $includeSelf = false; if (str_starts_with($path, $home) === false) { - return null; + try { + $resolved = $this->homeNodeOf(node: $node, home: $home); + } catch (Throwable $e) { + $this->logger->warning( + '[FrozenNodeWriteListener] A file write was refused: node {node} is owned by the app account but could not be placed in its home.', + ['node' => $this->nodeId(node: $node), 'path' => $path, 'exception' => $e] + ); + return new ObjectStateWriteException( + message: 'Cannot write to this file: the object that owns it could not be checked. Try again later.', + previous: $e + ); + } + + if ($resolved === null) { + return null; + } + + $anchor = $resolved['node']; + $includeSelf = ($resolved['exact'] === false); } - $candidate = $this->candidateOwnerFolder(node: $node); + $candidate = $this->candidateOwnerFolder(node: $anchor, includeSelf: $includeSelf); if ($candidate === null) { return null; } @@ -209,18 +237,75 @@ private function refusalFor(Node $node): ?ObjectStateWriteException { return $this->guard->refusalFor(object: $object); }//end refusalFor() + /** + * The node in the `openregister` home that a node outside it stands for, found by file id. + * + * A node reached through a share has the path of the acting user's tree, + * but is the same file as the one in the home. Only a node owned by the + * `openregister` account can be one, so everything else answers null + * without a lookup. A node that does not exist yet (a write or a rename + * target) has no id: its nearest existing ancestor stands for it, and + * `exact` says that it is not the node itself. + * + * @param Node $node The node about to change. + * @param string $home The `openregister` home prefix, with trailing slash. + * + * @return array{node: Node, exact: bool}|null The twin in the home, or null when this node is not one of the app's files. + * + * @throws Throwable When the home cannot be searched (the caller refuses). + */ + private function homeNodeOf(Node $node, string $home): ?array { + $current = $node; + $exact = true; + for ($depth = 0; $depth < self::MAX_DEPTH; $depth++) { + try { + $owner = $current->getOwner(); + $identifier = $current->getId(); + } catch (Throwable $e) { + // Not stored yet: the nearest existing ancestor decides. + try { + $current = $current->getParent(); + } catch (Throwable $parentError) { + return null; + } + + $exact = false; + continue; + } + + if ($owner === null || $owner->getUID() !== FileOwnershipHandler::APP_USER) { + return null; + } + + $twins = $this->rootFolder->getUserFolder(FileOwnershipHandler::APP_USER)->getById($identifier); + foreach ($twins as $twin) { + if (str_starts_with($twin->getPath(), $home) === true) { + return ['node' => $twin, 'exact' => $exact]; + } + } + + return null; + } + + return null; + }//end homeNodeOf() + /** * The nearest ancestor of a node named like a UUID, with its folder id. * - * @param Node $node The node. + * @param Node $node The node. + * @param bool $includeSelf Whether the node itself may be the owner folder (a node standing for a not-yet-existing child). * * @return array{uuid: string, folderId: int|string}|null The candidate, or null when no ancestor qualifies. */ - private function candidateOwnerFolder(Node $node): ?array { + private function candidateOwnerFolder(Node $node, bool $includeSelf = false): ?array { $current = $node; for ($depth = 0; $depth < self::MAX_DEPTH; $depth++) { try { - $current = $current->getParent(); + if ($depth > 0 || $includeSelf === false) { + $current = $current->getParent(); + } + $name = $current->getName(); } catch (Throwable $e) { return null; diff --git a/tests/Unit/Listener/FrozenNodeWriteListenerTest.php b/tests/Unit/Listener/FrozenNodeWriteListenerTest.php index 364a4d31be..1dab905555 100644 --- a/tests/Unit/Listener/FrozenNodeWriteListenerTest.php +++ b/tests/Unit/Listener/FrozenNodeWriteListenerTest.php @@ -35,8 +35,11 @@ use OCP\Files\Events\Node\BeforeNodeWrittenEvent; use OCP\Files\File; use OCP\Files\Folder; +use OCP\Files\IRootFolder; use OCP\Files\Node; +use OCP\Files\NotFoundException; use OCP\HintException; +use OCP\IUser; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -54,16 +57,50 @@ class FrozenNodeWriteListenerTest extends TestCase { private LoggerInterface&MockObject $logger; + private IRootFolder&MockObject $rootFolder; + protected function setUp(): void { parent::setUp(); $this->objects = $this->createMock(MagicMapper::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->rootFolder = $this->createMock(IRootFolder::class); }//end setUp() private function listener(): FrozenNodeWriteListener { - return new FrozenNodeWriteListener(guard: new FileWriteGuard(), objects: $this->objects, logger: $this->logger); + return new FrozenNodeWriteListener(guard: new FileWriteGuard(), objects: $this->objects, logger: $this->logger, rootFolder: $this->rootFolder); }//end listener() + /** + * The app account's home answers `getById` with the given twins. + * + * @param array $twins The nodes in the home that carry the id. + */ + private function homeHolds(array $twins): void { + $home = $this->createMock(Folder::class); + $home->method('getById')->willReturn($twins); + $this->rootFolder->method('getUserFolder')->with('openregister')->willReturn($home); + }//end homeHolds() + + private function owner(string $uid): IUser&MockObject { + $owner = $this->createMock(IUser::class); + $owner->method('getUID')->willReturn($uid); + + return $owner; + }//end owner() + + /** + * The file as the sharee alice sees it: `/alice/files//levering-manifest.pdf`, owned by the app account. + */ + private function fileThroughAShare(string $ownerUid = 'openregister'): File&MockObject { + $file = $this->createMock(File::class); + $file->method('getName')->willReturn('levering-manifest.pdf'); + $file->method('getId')->willReturn(9001); + $file->method('getPath')->willReturn('/alice/files/Dossier/levering-manifest.pdf'); + $file->method('getOwner')->willReturn($this->owner($ownerUid)); + + return $file; + }//end fileThroughAShare() + /** * A folder node with a name, an id and a parent. */ @@ -142,6 +179,69 @@ public function testADeleteAndARenameAreAbortedThroughAbortOperation(): void { $this->listener()->handle(new BeforeNodeRenamedEvent($this->fileInObjectFolder(), $outside)); }//end testADeleteAndARenameAreAbortedThroughAbortOperation() + public function testAWriteThroughAShareeMountIsAborted(): void { + $this->objects->method('findAcrossAllSources')->willReturn(['object' => $this->frozenObject()]); + $this->homeHolds([$this->fileInObjectFolder()]); + + try { + $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileThroughAShare())); + $this->fail('A write through a share into a frozen object went through'); + } catch (HintException $e) { + $this->assertStringContainsString('frozen by dossiq', $e->getMessage()); + } + + try { + $this->listener()->handle(new BeforeNodeDeletedEvent($this->fileThroughAShare())); + $this->fail('A delete through a share of a frozen object went through'); + } catch (AbortedEventException $e) { + $this->assertStringContainsString('frozen by dossiq', $e->getMessage()); + } + + // A rename of the shared file to somewhere in alice's own tree: the source is the shared file. + $own = $this->createMock(File::class); + $own->method('getPath')->willReturn('/alice/files/mine.pdf'); + $own->method('getOwner')->willReturn($this->owner('alice')); + + $this->expectException(AbortedEventException::class); + $this->listener()->handle(new BeforeNodeRenamedEvent($this->fileThroughAShare(), $own)); + }//end testAWriteThroughAShareeMountIsAborted() + + public function testACreateThroughAShareeMountResolvesItsNearestExistingAncestor(): void { + $this->objects->method('findAcrossAllSources')->willReturn(['object' => $this->frozenObject()]); + // The shared folder, as the home holds it: `/openregister/files/Woo Register/`. + $files = $this->folder('files', 2, '/openregister/files', null); + $register = $this->folder('Woo Register', 3, '/openregister/files/Woo Register', $files); + $objectFolder = $this->folder(self::UUID, self::FOLDER_ID, '/openregister/files/Woo Register/' . self::UUID, $register); + $this->homeHolds([$objectFolder]); + + $shared = $this->createMock(Folder::class); + $shared->method('getId')->willReturn(self::FOLDER_ID); + $shared->method('getOwner')->willReturn($this->owner('openregister')); + $new = $this->createMock(File::class); + $new->method('getPath')->willReturn('/alice/files/Dossier/new.pdf'); + $new->method('getOwner')->willThrowException(new NotFoundException('not stored yet')); + $new->method('getParent')->willReturn($shared); + + $this->expectException(HintException::class); + $this->listener()->handle(new BeforeNodeCreatedEvent($new)); + }//end testACreateThroughAShareeMountResolvesItsNearestExistingAncestor() + + public function testASharedNodeOfAnotherOwnerIsIgnoredWithoutALookup(): void { + $this->objects->expects($this->never())->method('findAcrossAllSources'); + $this->rootFolder->expects($this->never())->method('getUserFolder'); + + $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileThroughAShare(ownerUid: 'bob'))); + $this->addToAssertionCount(1); + }//end testASharedNodeOfAnotherOwnerIsIgnoredWithoutALookup() + + public function testAShareeNodeThatCannotBePlacedInTheHomeIsRefused(): void { + $this->rootFolder->method('getUserFolder')->willThrowException(new \RuntimeException('home unavailable')); + $this->logger->expects($this->once())->method('warning')->with($this->stringContains('could not be placed'), $this->anything()); + + $this->expectException(HintException::class); + $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileThroughAShare())); + }//end testAShareeNodeThatCannotBePlacedInTheHomeIsRefused() + public function testAnUnfrozenObjectFolderAcceptsTheWrite(): void { $object = $this->frozenObject(); $object->setFrozen(null); From f1f9c1d83add977ebb0e781499e87a1e6e757648 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:42:58 +0200 Subject: [PATCH 05/37] =?UTF-8?q?fix(review):=20#4580=20plat-f2=20?= =?UTF-8?q?=E2=80=94=20Any=20admin-UI=20file-settings=20save=20clears=20th?= =?UTF-8?q?e=20instance=20review=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateFileSettingsOnly() writes anonymisationReview only when the request carries the key; without it the stored declaration is kept, and an explicit null still clears it. The test that asserted null after an empty save now asserts the kept value, plus the explicit-null clear. The Vue form is left alone: the server keeps the stored value, so the form need not send it. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660645 Sweep: ReviewPolicy::SETTINGS_KEY is written nowhere else (ReviewPolicyResolver only reads it). Check: phpunit tests/Unit/Service/Settings/FileSettingsHandlerTest.php OK 20 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Settings/FileSettingsHandler.php | 19 ++++++++++--- .../Settings/FileSettingsHandlerTest.php | 27 +++++++++++++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/lib/Service/Settings/FileSettingsHandler.php b/lib/Service/Settings/FileSettingsHandler.php index c7c6ef230a..2d42098f02 100644 --- a/lib/Service/Settings/FileSettingsHandler.php +++ b/lib/Service/Settings/FileSettingsHandler.php @@ -255,7 +255,9 @@ public function updateFileSettingsOnly(array $fileData): array { 'entityRecognitionMethod' => $fileData['entityRecognitionMethod'] ?? 'auto', // Auto (unconfigured marker), regex, presidio, openanonymiser, llm, hybrid. // Who owns a redaction decision on a file that belongs to no - // schema (reviewer-owns-their-decisions); null is open. + // schema (reviewer-owns-their-decisions); null is open. A save + // that does not carry the key keeps the stored declaration (the + // admin form never sends it); an explicit null clears it. ReviewPolicy::SETTINGS_KEY => $this->reviewDefault(fileData: $fileData), ]; @@ -278,16 +280,27 @@ public function updateFileSettingsOnly(array $fileData): array { /** * The instance review default from a settings save, refused when unsound. * + * A save without the key keeps the stored declaration; an explicit null clears it. + * * @param array $fileData The settings being saved. * - * @return array|null The declaration, or null when none is given. + * @return array|null The declaration to store, or null when there is none. * * @throws InvalidArgumentException When the declaration names an ownership nobody can honour. * * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 */ private function reviewDefault(array $fileData): ?array { - $declaration = ($fileData[ReviewPolicy::SETTINGS_KEY] ?? null); + if (array_key_exists(ReviewPolicy::SETTINGS_KEY, $fileData) === false) { + $stored = ($this->getFileSettingsOnly()[ReviewPolicy::SETTINGS_KEY] ?? null); + if (is_array($stored) === true) { + return $stored; + } + + return null; + } + + $declaration = $fileData[ReviewPolicy::SETTINGS_KEY]; if ($declaration === null) { return null; } diff --git a/tests/Unit/Service/Settings/FileSettingsHandlerTest.php b/tests/Unit/Service/Settings/FileSettingsHandlerTest.php index 553a807daa..f88880c442 100644 --- a/tests/Unit/Service/Settings/FileSettingsHandlerTest.php +++ b/tests/Unit/Service/Settings/FileSettingsHandlerTest.php @@ -355,10 +355,33 @@ public function testUpdateFileSettingsStoresCorrectJson(): void { * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 */ public function testTheReviewDefaultIsKeptAndChecked(): void { + $stored = ''; + $this->appConfig->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value) use (&$stored): bool { + if ($key === 'fileManagement') { + $stored = $value; + } + + return true; + } + ); + $this->appConfig->method('getValueString')->willReturnCallback( + static function (string $app, string $key, string $default = '') use (&$stored): string { + return ($key === 'fileManagement' ? $stored : $default); + } + ); + + $kept = ['ownership' => 'decider', 'supervisors' => ['woo-coordinator'], 'hideOthers' => false]; $result = $this->handler->updateFileSettingsOnly(['anonymisationReview' => ['ownership' => 'decider', 'supervisors' => ['woo-coordinator']]]); - $this->assertSame(['ownership' => 'decider', 'supervisors' => ['woo-coordinator'], 'hideOthers' => false], $result['anonymisationReview']); + $this->assertSame($kept, $result['anonymisationReview']); + + // The admin form never sends the key: a save without it keeps the stored default. + $this->assertSame($kept, $this->handler->updateFileSettingsOnly(['chunkSize' => 500])['anonymisationReview']); + $this->assertSame($kept, $this->handler->getFileSettingsOnly()['anonymisationReview']); - $this->assertNull($this->handler->updateFileSettingsOnly([])['anonymisationReview']); + // An explicit null clears it. + $this->assertNull($this->handler->updateFileSettingsOnly(['anonymisationReview' => null])['anonymisationReview']); + $this->assertNull($this->handler->getFileSettingsOnly()['anonymisationReview']); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('decidr'); From 02b92226c4f8aad853d37bb9866b2e2f37f2f923 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:46:01 +0200 Subject: [PATCH 06/37] =?UTF-8?q?fix(review):=20#4580=20plat-f3=20?= =?UTF-8?q?=E2=80=94=20Deferred-chunk=20limit=20is=2032000,=20but=20NC=203?= =?UTF-8?q?2=20and=2033=20accept=204000?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chunk budget now follows the running Nextcloud: ListenerDeferralService::getArgumentLimit() reads OC\BackgroundJob\JobList::MAX_ARGUMENT_JSON_LENGTH when the server defines it (34 and later: 32000) and uses 4000 otherwise, which is what NC 32 and 33 enforce. Picking by constant rather than a fixed 4000 keeps 35 at full chunks; the 4000 fallback is the safe side everywhere. The limit can be injected through the constructor (optional, autowired as null). An entry that alone exceeds the limit is enqueued without its oldData and the reduction logged at warning level (the 'updated' notification is still sent, only calculatedChange is skipped); an entry still too large is dropped and named as before. MAX_ARGUMENT_LENGTH is replaced by ARGUMENT_LENGTH_NC32/NC34 (no other user). DeferredChunksFitTheJobLimitTest runs at 4000 and at 32000 and covers the reduction; the 'too large for any job' test now uses an entry whose size is not oldData. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660676 Sweep: the openspec change deferred-listener-batches-stay-under-the-job-limit (proposal + spec requirement) restated 32,000 and is updated to the runtime limit and the reduction. Check: phpunit tests/Unit/Service/Deferral + ObjectCleanupJobTest OK 34 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- .../Deferral/ListenerDeferralService.php | 93 ++++++++++++-- .../proposal.md | 5 +- .../specs/event-driven-architecture/spec.md | 10 +- .../DeferredChunksFitTheJobLimitTest.php | 120 +++++++++++++++--- 4 files changed, 195 insertions(+), 33 deletions(-) diff --git a/lib/Service/Deferral/ListenerDeferralService.php b/lib/Service/Deferral/ListenerDeferralService.php index 0a7db1e081..a76845dd9c 100644 --- a/lib/Service/Deferral/ListenerDeferralService.php +++ b/lib/Service/Deferral/ListenerDeferralService.php @@ -73,13 +73,28 @@ class ListenerDeferralService { public const DEFAULT_CHUNK_SIZE = 100; /** - * The longest job argument Nextcloud accepts, as its own JSON encoding - * (`OC\BackgroundJob\JobList::MAX_ARGUMENT_JSON_LENGTH`). Longer, and - * `IJobList::add()` throws and the whole chunk is lost. + * The longest job argument Nextcloud 32 and 33 accept, as its own JSON + * encoding (a literal in `OC\BackgroundJob\JobList::add()` there). Also the + * limit used when the running server does not name its own. * * @var integer */ - public const MAX_ARGUMENT_LENGTH = 32000; + public const ARGUMENT_LENGTH_NC32 = 4000; + + /** + * The longest job argument Nextcloud 34 and later accept + * (`OC\BackgroundJob\JobList::MAX_ARGUMENT_JSON_LENGTH`, introduced in 34). + * + * @var integer + */ + public const ARGUMENT_LENGTH_NC34 = 32000; + + /** + * The server's own name for the limit, defined from Nextcloud 34 on. + * + * @var string + */ + private const SERVER_LIMIT_CONSTANT = 'OC\\BackgroundJob\\JobList::MAX_ARGUMENT_JSON_LENGTH'; /** * App-config key of the deferral kill switch. @@ -138,6 +153,7 @@ class ListenerDeferralService { * @param IJobList $jobList Job list the chunk jobs are enqueued on. * @param IAppConfig $appConfig App config holding the kill switch. * @param LoggerInterface $logger PSR logger. + * @param int|null $argumentLimit Job argument limit to respect; null reads the running server's. * * @return void */ @@ -147,9 +163,37 @@ public function __construct( private readonly IJobList $jobList, private readonly IAppConfig $appConfig, private readonly LoggerInterface $logger, + private readonly ?int $argumentLimit = null, ) { }//end __construct() + /** + * The longest job argument the running Nextcloud accepts, as JSON. + * + * The limit is 4000 characters on Nextcloud 32 and 33 and 32000 from 34 + * on; `info.xml` supports 32 up to 35, so it is read from the server's own + * constant when that exists and is 4000 otherwise (the safe side: a chunk + * under 4000 is accepted everywhere). + * + * @return int The limit in characters. + * + * @spec openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md#requirement-a-deferred-listener-chunk-fits-the-job-argument-limit + */ + public function getArgumentLimit(): int { + if ($this->argumentLimit !== null) { + return $this->argumentLimit; + } + + if (defined(self::SERVER_LIMIT_CONSTANT) === true) { + $serverLimit = constant(self::SERVER_LIMIT_CONSTANT); + if (is_int($serverLimit) === true && $serverLimit > 0) { + return $serverLimit; + } + } + + return self::ARGUMENT_LENGTH_NC32; + }//end getArgumentLimit() + /** * Whether deferral is enabled on this instance. * @@ -259,12 +303,14 @@ public function flushAll(): void { * * A chunk is cut by size as well as by count: an entry may carry the * object's previous data (an update notification's `oldData`), and 25 of - * those passed Nextcloud's 32,000-character job argument limit. The job - * list then refused the whole chunk and every entry in it was dropped - * with one log line (zuiddrecht live test, 14 October 2026: 11 chunks of - * 25 in one cron run). Now the entries are spread over as many jobs as - * the limit needs; only an entry that alone exceeds it is dropped, and it - * is named in the log. + * those passed Nextcloud's job argument limit (4000 characters on 32 and + * 33, 32000 from 34). The job list then refused the whole chunk and every + * entry in it was dropped with one log line (zuiddrecht live test, 14 + * October 2026: 11 chunks of 25 in one cron run). Now the entries are + * spread over as many jobs as the running server's limit needs. An entry + * that alone exceeds it goes without its `oldData` (the notification is + * still sent, and the log names the reduction); only an entry that is + * still too large after that is dropped, and it is named in the log. * * @param string $jobClass FQCN of the job to enqueue. * @@ -299,13 +345,37 @@ private function chunksUnderLimit(string $jobClass, array $entries): array { $envelope = new DeferredListenerContext(userId: $this->capturedUserId, orgUuid: $this->capturedOrgUuid, entries: []); // Measured the way the job list measures: plain json_encode. Each entry // adds its own length plus the comma that joins it to the next. - $budget = (self::MAX_ARGUMENT_LENGTH - strlen((string)json_encode($envelope->toJobArguments()))); + $budget = ($this->getArgumentLimit() - strlen((string)json_encode($envelope->toJobArguments()))); $chunks = []; $current = []; $used = 0; foreach ($entries as $entry) { $length = (strlen((string)json_encode($entry)) + 1); + if ($length > $budget && array_key_exists('oldData', $entry) === true) { + // The previous data is the bulk of a large update. Without it the + // 'updated' notification is still sent; only the comparison of old + // against new is skipped. + $reduced = $entry; + unset($reduced['oldData']); + $reducedLength = (strlen((string)json_encode($reduced)) + 1); + if ($reducedLength <= $budget) { + $this->logger->warning( + message: '[ListenerDeferralService] A deferred listener entry was too large for a background job; it was enqueued without its previous data', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'jobClass' => $jobClass, + 'uuid' => (string)($entry['uuid'] ?? ''), + 'length' => $length, + 'limit' => $this->getArgumentLimit(), + ] + ); + $entry = $reduced; + $length = $reducedLength; + } + } + if ($length > $budget) { $this->logger->error( message: '[ListenerDeferralService] A deferred listener entry is larger than a background job may carry; it was not enqueued', @@ -315,6 +385,7 @@ private function chunksUnderLimit(string $jobClass, array $entries): array { 'jobClass' => $jobClass, 'uuid' => (string)($entry['uuid'] ?? ''), 'length' => $length, + 'limit' => $this->getArgumentLimit(), ] ); continue; diff --git a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md index 5dc0bb6005..292ccb060f 100644 --- a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md +++ b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md @@ -16,8 +16,9 @@ Zuiddrecht live test, 14 October 2026: one cron run that swept hundreds of dossi ## What changes -- `ListenerDeferralService::flushBuffer()` splits the buffer into consecutive chunks whose JSON argument stays within `MAX_ARGUMENT_LENGTH` (32,000), measured the way Nextcloud's job list measures it, and enqueues each. -- An entry larger than any job may carry is logged at error level with its job class and uuid, and the rest of its chunk still goes. +- `ListenerDeferralService::flushBuffer()` splits the buffer into consecutive chunks whose JSON argument stays within the running server's job argument limit (`ListenerDeferralService::getArgumentLimit()`: `JobList::MAX_ARGUMENT_JSON_LENGTH` where Nextcloud defines it, 32,000 from 34, else the 4,000 of Nextcloud 32 and 33), measured the way Nextcloud's job list measures it, and enqueues each. +- An entry larger than the limit is enqueued without its `oldData` (the update notification is still sent) and the reduction is logged at warning level. +- An entry still too large after that is logged at error level with its job class and uuid, and the rest of its chunk still goes. ## Screens diff --git a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md index a8d185f402..b28f54cbbf 100644 --- a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md +++ b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md @@ -4,14 +4,20 @@ ### Requirement: A deferred listener chunk fits the job argument limit -The listener deferral service SHALL enqueue a buffered chunk as one or more background jobs whose JSON-encoded argument each stays within Nextcloud's limit of 32,000 characters, keeping every entry once and in order. An entry that alone exceeds the limit SHALL be left out and logged with its job class and uuid; the other entries of its chunk SHALL still be enqueued. +The listener deferral service SHALL enqueue a buffered chunk as one or more background jobs whose JSON-encoded argument each stays within the running Nextcloud's job argument limit (4,000 characters on Nextcloud 32 and 33, 32,000 from 34; read from the server when it names the limit, 4,000 otherwise), keeping every entry once and in order. An entry that alone exceeds the limit SHALL be enqueued without its `oldData` and the reduction logged; an entry that still exceeds it SHALL be left out and logged with its job class and uuid. The other entries of its chunk SHALL still be enqueued. #### Scenario: Twenty-five updates of large cases - GIVEN a schema that declares update notifications and 25 updates of cases of about 2,000 characters each in one request - WHEN the buffer is flushed -- THEN every update reaches the job list once, in order, spread over more than one job, and no job argument is longer than 32,000 characters +- THEN every update reaches the job list once, in order, spread over more than one job, and no job argument is longer than the active limit (checked at 4,000 and at 32,000) - @e2e exclude pinned through `AnnotationNotificationListener` by `DeferredChunksFitTheJobLimitTest::testAChunkOfLargeUpdatesIsSpreadOverJobsTheListTakes` +#### Scenario: An entry too large for the active limit keeps its notification +- GIVEN an update whose previous data alone makes its entry longer than the active limit +- WHEN the buffer is flushed +- THEN the entry is enqueued without `oldData`, the log names its uuid, and the notification is still sent +- @e2e exclude pinned by `DeferredChunksFitTheJobLimitTest::testAnOversizedUpdateLosesItsOldDataNotItsNotification` + #### Scenario: One entry too large for any job - GIVEN three updates of which the middle one is larger than a job may carry - WHEN the buffer is flushed diff --git a/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php b/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php index fdb75289c4..ebbcd3214a 100644 --- a/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php +++ b/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php @@ -5,9 +5,10 @@ * * Drives AnnotationNotificationListener, the caller whose update entries * carry the object's previous data, into the real ListenerDeferralService, - * with a job list that refuses an argument over 32,000 characters the way - * Nextcloud's does. Before this change one cron run lost eleven chunks of 25 - * update notifications that way (zuiddrecht live test, 14 October 2026). + * with a job list that refuses an argument over the limit the way + * Nextcloud's does: 4,000 characters on Nextcloud 32 and 33, 32,000 from 34. + * Before this change one cron run lost eleven chunks of 25 update + * notifications that way (zuiddrecht live test, 14 October 2026). * * @category Tests * @package OCA\OpenRegister\Tests\Unit\Service\Deferral @@ -75,6 +76,13 @@ class DeferredChunksFitTheJobLimitTest extends TestCase { */ private array $errors = []; + /** + * What was logged at warning level. + * + * @var array}> + */ + private array $warnings = []; + /** * Reset the shutdown spy. * @@ -88,15 +96,17 @@ protected function setUp(): void { /** * The real deferral service on a job list that enforces the limit. * + * @param int $limit The job argument limit of the simulated Nextcloud. + * * @return ListenerDeferralService */ - private function deferral(): ListenerDeferralService { + private function deferral(int $limit = ListenerDeferralService::ARGUMENT_LENGTH_NC34): ListenerDeferralService { $jobList = $this->createMock(IJobList::class); $jobList->method('add')->willReturnCallback( - function (string $job, mixed $argument): void { + function (string $job, mixed $argument) use ($limit): void { // The check Nextcloud's JobList::add() makes. - if (strlen((string)json_encode($argument)) > 32000) { - throw new InvalidArgumentException("Background job arguments can't exceed 32000 characters (json encoded)"); + if (strlen((string)json_encode($argument)) > $limit) { + throw new InvalidArgumentException("Background job arguments can't exceed " . $limit . ' characters (json encoded)'); } $this->enqueued[] = $argument; @@ -109,6 +119,11 @@ function (string $message, array $context = []): void { $this->errors[] = [$message, $context]; } ); + $logger->method('warning')->willReturnCallback( + function (string $message, array $context = []): void { + $this->warnings[] = [$message, $context]; + } + ); return new ListenerDeferralService( userSession: $this->createMock(IUserSession::class), @@ -116,6 +131,7 @@ function (string $message, array $context = []): void { jobList: $jobList, appConfig: $this->createMock(IAppConfig::class), logger: $logger, + argumentLimit: $limit, ); }//end deferral() @@ -186,57 +202,125 @@ private function enqueuedUuids(): array { }//end enqueuedUuids() /** - * Twenty-five updates of cases of about 2,000 characters each, one - * listener chunk, all reach the job list. + * The limits of the Nextcloud versions this app supports, with an entry size that needs spreading at each. + * + * @return array Limit and description length. + */ + public static function limits(): array { + return [ + 'Nextcloud 34 and later: 32,000' => [32000, 2000], + 'Nextcloud 32 and 33: 4,000' => [4000, 500], + ]; + }//end limits() + + /** + * Twenty-five updates of large cases, one listener chunk, all reach the + * job list, whichever limit the running Nextcloud has. + * + * @param int $limit The job argument limit. + * @param int $length The length of each case's description. * * @return void * + * @dataProvider limits + * * @spec openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md#requirement-a-deferred-listener-chunk-fits-the-job-argument-limit */ - public function testAChunkOfLargeUpdatesIsSpreadOverJobsTheListTakes(): void { - $deferral = $this->deferral(); + public function testAChunkOfLargeUpdatesIsSpreadOverJobsTheListTakes(int $limit, int $length): void { + $deferral = $this->deferral(limit: $limit); $listener = $this->listener(deferral: $deferral); $expected = []; for ($i = 1; $i <= 25; $i++) { $uuid = 'case-' . $i; $expected[] = $uuid; - $listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: $uuid, length: 2000), $this->caseOf(uuid: $uuid, length: 2000))); + $listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: $uuid, length: $length), $this->caseOf(uuid: $uuid, length: $length))); } $deferral->flushAll(); self::assertSame([], $this->errors, 'nothing was refused'); + self::assertSame([], $this->warnings, 'nothing had to be reduced'); self::assertSame($expected, $this->enqueuedUuids(), 'every update, once, in order'); self::assertGreaterThan(1, count($this->enqueued), 'more than one job'); foreach ($this->enqueued as $argument) { - self::assertLessThanOrEqual(ListenerDeferralService::MAX_ARGUMENT_LENGTH, strlen((string)json_encode($argument))); + self::assertLessThanOrEqual($limit, strlen((string)json_encode($argument))); } self::assertSame([AnnotationNotificationDispatchJob::class], array_values(array_unique($this->jobClasses))); }//end testAChunkOfLargeUpdatesIsSpreadOverJobsTheListTakes() /** - * One update too large for any job is named in the log; the others in - * its chunk still go. + * An update whose previous data alone passes the limit is enqueued without + * it: the notification is not lost, and the log names the reduction. * * @return void * * @spec openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md#requirement-a-deferred-listener-chunk-fits-the-job-argument-limit */ - public function testAnEntryTooLargeForAnyJobIsNamedAndTheRestGo(): void { - $deferral = $this->deferral(); + public function testAnOversizedUpdateLosesItsOldDataNotItsNotification(): void { + $deferral = $this->deferral(limit: ListenerDeferralService::ARGUMENT_LENGTH_NC32); $listener = $this->listener(deferral: $deferral); $listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: 'small-1', length: 100), $this->caseOf(uuid: 'small-1', length: 100))); - $listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: 'huge', length: 40000), $this->caseOf(uuid: 'huge', length: 40000))); + $listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: 'big', length: 5000), $this->caseOf(uuid: 'big', length: 5000))); $listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: 'small-2', length: 100), $this->caseOf(uuid: 'small-2', length: 100))); $deferral->flushAll(); + self::assertSame([], $this->errors, 'nothing was dropped'); + self::assertSame(['small-1', 'big', 'small-2'], $this->enqueuedUuids()); + self::assertCount(1, $this->warnings); + self::assertSame('big', $this->warnings[0][1]['uuid']); + foreach ($this->enqueued as $argument) { + foreach ($argument['entries'] as $entry) { + self::assertSame($entry['uuid'] !== 'big', array_key_exists('oldData', $entry) && $entry['oldData'] !== null); + } + } + }//end testAnOversizedUpdateLosesItsOldDataNotItsNotification() + + /** + * One entry too large for any job, whatever it carries, is named in the + * log; the others in its chunk still go. + * + * @return void + * + * @spec openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md#requirement-a-deferred-listener-chunk-fits-the-job-argument-limit + */ + public function testAnEntryTooLargeForAnyJobIsNamedAndTheRestGo(): void { + $deferral = $this->deferral(); + $deferral->defer(jobClass: AnnotationNotificationDispatchJob::class, entry: ['uuid' => 'small-1', 'trigger' => 'updated']); + $deferral->defer(jobClass: AnnotationNotificationDispatchJob::class, entry: ['uuid' => 'huge', 'trigger' => 'updated', 'payload' => str_repeat('x', 40000)]); + $deferral->defer(jobClass: AnnotationNotificationDispatchJob::class, entry: ['uuid' => 'small-2', 'trigger' => 'updated']); + + $deferral->flushAll(); + self::assertSame(['small-1', 'small-2'], $this->enqueuedUuids()); self::assertCount(1, $this->errors); self::assertSame('huge', $this->errors[0][1]['uuid']); }//end testAnEntryTooLargeForAnyJobIsNamedAndTheRestGo() + /** + * Without an injected limit the service reads the running server's, and falls back to 4,000. + * + * @return void + */ + public function testTheLimitComesFromTheServerWhenItNamesOne(): void { + $service = new ListenerDeferralService( + userSession: $this->createMock(IUserSession::class), + organisation: $this->createMock(OrganisationService::class), + jobList: $this->createMock(IJobList::class), + appConfig: $this->createMock(IAppConfig::class), + logger: $this->createMock(LoggerInterface::class), + ); + + $serverConstant = 'OC\\BackgroundJob\\JobList::MAX_ARGUMENT_JSON_LENGTH'; + $expected = ListenerDeferralService::ARGUMENT_LENGTH_NC32; + if (defined($serverConstant) === true) { + $expected = constant($serverConstant); + } + + self::assertSame($expected, $service->getArgumentLimit()); + }//end testTheLimitComesFromTheServerWhenItNamesOne() + /** * Small entries still go as one job per chunk. * From 227bc52ed7bceab19d6388a11f077e93257c983d Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:46:53 +0200 Subject: [PATCH 07/37] =?UTF-8?q?fix(review):=20#4580=20eng-f1=20=E2=80=94?= =?UTF-8?q?=20Upload=20endpoint=20ignores=20the=20form's=20audience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormsController::upload() applies submit()'s audience rule (shared audienceRefuses()/signInRequired() helpers): an anonymous visitor of a form that is not public gets 401 before any rule lookup or write. FormUploadStore also gets a 20 MiB default cap for a file property that declares no maxSize (surface slice's duplicate of this finding); a declared limit, larger or smaller, is kept. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660403 Sweep: FormUploadStore::issue has one caller (FormsController::upload). Check: phpunit FormsControllerTest + FormUploadStoreTest OK 27 tests; phpcs on the lib files clean. Co-Authored-By: Claude Opus 5.5 --- lib/Controller/FormsController.php | 34 +++++++++++++++++-- lib/Service/Form/FormUploadStore.php | 17 +++++++++- tests/Unit/Controller/FormsControllerTest.php | 18 ++++++++++ .../Unit/Service/Form/FormUploadStoreTest.php | 15 ++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/lib/Controller/FormsController.php b/lib/Controller/FormsController.php index 33b28f4f7d..1cba865995 100644 --- a/lib/Controller/FormsController.php +++ b/lib/Controller/FormsController.php @@ -167,8 +167,8 @@ public function submit(string $formId): JSONResponse { try { $form = $this->resolveForm(formId: $formId); $subject = $this->userSession->getUser(); - if ($subject === null && $form['audience'] !== 'public') { - return new JSONResponse(data: ['message' => $this->l10n->t('Please sign in to send this form.'), 'findings' => []], statusCode: 401); + if ($this->audienceRefuses(form: $form, subject: $subject) === true) { + return $this->signInRequired(); } $key = trim($this->request->getHeader('Idempotency-Key')); @@ -253,7 +253,8 @@ public function draft(string $formId): JSONResponse { * * @param string $formId The form object's uuid. * - * @return JSONResponse 201 `{ token, expiresAt }`; 404 or 422 with `{ message, findings }`. + * @return JSONResponse 201 `{ token, expiresAt }`; 401 for an anonymous visitor of a sign-in-only form; + * 404 or 422 with `{ message, findings }`. * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire */ @@ -271,6 +272,12 @@ public function upload(string $formId): JSONResponse { try { $form = $this->resolveForm(formId: $formId); + // The token is only redeemable by submit(), which asks the audience; + // asking it here too keeps a closed form from storing bytes for anyone. + if ($this->audienceRefuses(form: $form, subject: $this->userSession->getUser()) === true) { + return $this->signInRequired(); + } + $rule = $this->ruleFor(writes: $form['writes'], property: $property); $issued = $this->uploads->issue(formId: $formId, property: $property, rule: $rule, file: $file); } catch (FormSubmitRefusedException $refused) { @@ -280,6 +287,27 @@ public function upload(string $formId): JSONResponse { return new JSONResponse(data: $issued, statusCode: 201); }//end upload() + /** + * Whether the form's audience turns this visitor away: anonymous, and the form is not public. + * + * @param array{id: string, writes: array>, audience: string} $form The form. + * @param IUser|null $subject The signed-in subject, null when anonymous. + * + * @return bool True when the visitor must sign in first. + */ + private function audienceRefuses(array $form, ?IUser $subject): bool { + return $subject === null && $form['audience'] !== 'public'; + }//end audienceRefuses() + + /** + * The 401 an anonymous visitor of a sign-in-only form gets. + * + * @return JSONResponse The refusal. + */ + private function signInRequired(): JSONResponse { + return new JSONResponse(data: ['message' => $this->l10n->t('Please sign in to send this form.'), 'findings' => []], statusCode: 401); + }//end signInRequired() + /** * One destination through FormSubmitService::submit(), a journey's writes through submitAll(). * diff --git a/lib/Service/Form/FormUploadStore.php b/lib/Service/Form/FormUploadStore.php index df4b29257e..da945869f6 100644 --- a/lib/Service/Form/FormUploadStore.php +++ b/lib/Service/Form/FormUploadStore.php @@ -60,6 +60,17 @@ class FormUploadStore { */ public const TTL = 86400; + /** + * The size cap, in bytes, of a file property that declares none (20 MiB). + * + * The upload endpoint is a public page, so "no `maxSize`" must not mean + * "no limit": it would store whatever PHP lets through for anyone. A + * property that declares a limit, larger or smaller, keeps it. + * + * @var int + */ + public const DEFAULT_MAX_BYTES = 20971520; + /** * Constructor. * @@ -106,7 +117,11 @@ public function issue(string $formId, string $property, array $rule, array $file $size = (int)($file['size'] ?? filesize($path)); $limit = $this->rules->maxSizeBytes(fileConfig: $fileConfig); - if ($limit > 0 && $size > $limit) { + if ($limit <= 0) { + $limit = self::DEFAULT_MAX_BYTES; + } + + if ($size > $limit) { $this->refuse( property: $property, code: 'file-too-large', diff --git a/tests/Unit/Controller/FormsControllerTest.php b/tests/Unit/Controller/FormsControllerTest.php index c50b68685d..35da89e797 100644 --- a/tests/Unit/Controller/FormsControllerTest.php +++ b/tests/Unit/Controller/FormsControllerTest.php @@ -284,6 +284,24 @@ public function testAnUploadAnswersAToken(): void { $this->assertSame(['token' => 'abc', 'expiresAt' => 1], $response->getData()); }//end testAnUploadAnswersAToken() + /** + * An anonymous visitor of a sign-in-only form gets a 401 and no token; a signed-in one gets through. + */ + public function testAnAnonymousUploadToASignInOnlyFormIs401(): void { + $this->params = ['property' => 'bijlage']; + $this->request->method('getUploadedFile')->willReturn(['name' => 'a.pdf', 'tmp_name' => '/tmp/x', 'error' => 0, 'size' => 5]); + $this->resolver->method('resolve')->willReturn($this->form(audience: 'authenticated')); + $schema = new Schema(); + $schema->setProperties(['bijlage' => ['type' => 'file']]); + $this->schemas->method('find')->willReturn($schema); + $this->uploads->expects($this->once())->method('issue')->willReturn(['token' => 'abc', 'expiresAt' => 1]); + + $this->assertSame(401, $this->controller->upload(formId: 'form-1')->getStatus()); + + $this->session->method('getUser')->willReturn($this->createMock(IUser::class)); + $this->assertSame(201, $this->controller->upload(formId: 'form-1')->getStatus()); + }//end testAnAnonymousUploadToASignInOnlyFormIs401() + /** * An upload for a property no destination of the form has is a 422, and no token. */ diff --git a/tests/Unit/Service/Form/FormUploadStoreTest.php b/tests/Unit/Service/Form/FormUploadStoreTest.php index 9dbbb64107..f1baae7d22 100644 --- a/tests/Unit/Service/Form/FormUploadStoreTest.php +++ b/tests/Unit/Service/Form/FormUploadStoreTest.php @@ -111,6 +111,21 @@ public function testAnOversizedFileIsRefused(): void { $this->assertSame([], ($this->appData->folders[FormUploadStore::FOLDER] ?? null)?->files ?? []); }//end testAnOversizedFileIsRefused() + /** + * A property without a size rule still has a cap: the endpoint is public. + */ + public function testAPropertyWithoutAMaxSizeHasADefaultCap(): void { + try { + $this->store->issue(formId: 'form-1', property: 'bijlage', rule: ['type' => 'file'], file: $this->upload('x', size: FormUploadStore::DEFAULT_MAX_BYTES + 1)); + $this->fail('A file over the default cap was given a token.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame('file-too-large', $refused->getFindings()[0]['code']); + } + + $issued = $this->store->issue(formId: 'form-1', property: 'bijlage', rule: ['type' => 'file'], file: $this->upload('x', size: 1024)); + $this->assertNotSame('', $issued['token']); + }//end testAPropertyWithoutAMaxSizeHasADefaultCap() + /** * A type the property does not allow is refused; an items rule of an array property counts. */ From 94854b289f0a00799ea921b717cdfe6890349a77 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:49:36 +0200 Subject: [PATCH 08/37] =?UTF-8?q?fix(review):=20#4580=20eng-f3=20=E2=80=94?= =?UTF-8?q?=20Idempotency=20key=20is=20neither=20subject-bound=20nor=20rac?= =?UTF-8?q?e-safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stored answer is now bound to the submitter (user id, scope value or anonymous marker) and a canonical hash of the payload: another subject, or the same key with another payload, gets a 409 that carries nothing of the first answer. A key is reserved with an exclusive ILockingProvider lock for the length of its submit (acquire, find, write, remember, release in a finally), so a parallel retry answers 409 and creates no second object; the lock is released after a refusal too. FormIdempotencyStore gets ILockingProvider and IL10N (autowired), find()/remember() take the binding, and an entry without a binding (older than this change, at most 24 h) matches nobody. Tests use a small in-memory MemoryLockingProvider. docs/features/form-destination.md gets the 409 row and the binding sentence (same sentence as the finding's). Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660446 Sweep: FormIdempotencyStore has one caller (FormSubmitService::submitAll) plus the purge job, which is unchanged; the idempotency header is read only in FormsController::submit. Check: phpunit tests/Unit/Service/Form + FormUploadPurgeJobTest + FormsControllerTest OK 106 tests; phpcs on the two lib files clean. The two-concurrent-requests Done-when is covered through a simulated parallel request inside the save, not a real parallel run. Co-Authored-By: Claude Opus 5.5 --- docs/features/form-destination.md | 5 +- lib/Service/Form/FormIdempotencyStore.php | 138 ++++++++++++++++-- lib/Service/Form/FormSubmitService.php | 66 +++++++-- .../Form/Fakes/MemoryLockingProvider.php | 79 ++++++++++ .../Service/Form/FormIdempotencyStoreTest.php | 80 ++++++++-- .../Service/Form/FormSubmitServiceTest.php | 89 ++++++++++- 6 files changed, 425 insertions(+), 32 deletions(-) create mode 100644 tests/Unit/Service/Form/Fakes/MemoryLockingProvider.php diff --git a/docs/features/form-destination.md b/docs/features/form-destination.md index c4869fa631..62cc522fe8 100644 --- a/docs/features/form-destination.md +++ b/docs/features/form-destination.md @@ -81,10 +81,13 @@ OpenRegister object (`status: published`, `destination` or `writes`, | 401 | The form is for signed-in people | | 403 | The subject may not create the destination | | 404 | No such published form (also counted as a brute-force attempt) | +| 409 | The `Idempotency-Key` is in use by a submit still running, or was used by another person or for another payload | | 422 | The payload was refused: `{ message, findings }`, nothing was created | | 503 | The destination could not be reached; the resident tries again later | -A retried `Idempotency-Key` returns the first answer for 24 hours. When one +A retried `Idempotency-Key` returns the first answer for 24 hours, to the same +person with the same payload only; the key is reserved while its submit runs, so +a parallel retry answers 409 instead of creating a second object. When one of several writes is refused, the earlier writes of that submit are deleted before the answer, and the deletion is logged. diff --git a/lib/Service/Form/FormIdempotencyStore.php b/lib/Service/Form/FormIdempotencyStore.php index 7bdd3fef3d..0e4b6d2cfa 100644 --- a/lib/Service/Form/FormIdempotencyStore.php +++ b/lib/Service/Form/FormIdempotencyStore.php @@ -9,7 +9,14 @@ * * Stored in app data, one small JSON file per key, named by a hash of the * form scope and the key so a key never names a path. It holds the answer - * (reference, id, receivedAt, confirmation), never the submitted payload. + * (reference, id, receivedAt, confirmation), never the submitted payload, and + * a binding: a hash of who submitted and of what. The key is client-chosen, so + * it alone never earns somebody the stored answer: another subject, or the + * same key with another payload, is refused with a 409 that reveals nothing. + * + * A key is reserved (an exclusive lock) while its submit runs, so a double + * click or a parallel retry with the same key waits out with a 409 instead of + * writing a second object. * * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 @@ -30,11 +37,15 @@ namespace OCA\OpenRegister\Service\Form; +use OCA\OpenRegister\Exception\FormSubmitRefusedException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Files\IAppData; use OCP\Files\NotFoundException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\IL10N; +use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; /** * Find, remember and purge submit answers by key. @@ -60,27 +71,88 @@ class FormIdempotencyStore { /** * Constructor. * - * @param IAppData $appData The app's data store. - * @param ITimeFactory $time The clock. + * @param IAppData $appData The app's data store. + * @param ITimeFactory $time The clock. + * @param ILockingProvider $locks Reserves a key while its submit runs. + * @param IL10N $l10n Translations, for refusals a resident reads. */ public function __construct( private readonly IAppData $appData, private readonly ITimeFactory $time, + private readonly ILockingProvider $locks, + private readonly IL10N $l10n, ) { }//end __construct() /** - * The answer remembered under a key, or null when none is live. + * What a stored answer is bound to: the submitter and the payload. + * + * @param string $subject The submitting subject (a user id, a scope value, or an anonymous marker). + * @param array $payload What was submitted. + * + * @return string The binding. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + public function bindingFor(string $subject, array $payload): string { + return hash('sha256', $subject . "\0" . json_encode($this->canonical(value: $payload))); + }//end bindingFor() + + /** + * Reserve a key for the length of its submit. * - * @param string $scope The form scope (form id, or the caller's own). + * @param string $scope The form scope. * @param string $key The Idempotency-Key. * + * @return void + * + * @throws FormSubmitRefusedException 409 when a submit with this key is still running. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference + */ + public function reserve(string $scope, string $key): void { + try { + $this->locks->acquireLock($this->lockPath(scope: $scope, key: $key), ILockingProvider::LOCK_EXCLUSIVE); + } catch (LockedException) { + throw new FormSubmitRefusedException( + message: $this->l10n->t('This form is already being sent. Please wait a moment.'), + status: 409 + ); + } + }//end reserve() + + /** + * Release a key reserved by reserve(). + * + * @param string $scope The form scope. + * @param string $key The Idempotency-Key. + * + * @return void + */ + public function release(string $scope, string $key): void { + try { + $this->locks->releaseLock($this->lockPath(scope: $scope, key: $key), ILockingProvider::LOCK_EXCLUSIVE); + } catch (\Throwable) { + // A lock that is already gone leaves nothing to release. + return; + } + }//end release() + + /** + * The answer remembered under a key, or null when none is live. + * + * @param string $scope The form scope (form id, or the caller's own). + * @param string $key The Idempotency-Key. + * @param string $binding The binding of this request (see bindingFor()). + * * @return array|null The answer. * + * @throws FormSubmitRefusedException 409 when the key was used by another subject or for another payload. + * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference */ - public function find(string $scope, string $key): ?array { + public function find(string $scope, string $key, string $binding): ?array { $folder = $this->folder(create: false); $name = $this->name(scope: $scope, key: $key); if ($folder === null || $folder->fileExists($name) === false) { @@ -95,6 +167,15 @@ public function find(string $scope, string $key): ?array { return null; } + if (hash_equals($entry['binding'], $binding) === false) { + // The same answer to a different asker would hand over their + // reference and confirmation; say only that the key is taken. + throw new FormSubmitRefusedException( + message: $this->l10n->t('This Idempotency-Key was already used for another submission.'), + status: 409 + ); + } + return $entry['response']; }//end find() @@ -104,14 +185,15 @@ public function find(string $scope, string $key): ?array { * @param string $scope The form scope. * @param string $key The Idempotency-Key. * @param array $response The answer. + * @param string $binding The binding of the request that got it (see bindingFor()). * * @return void * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference */ - public function remember(string $scope, string $key, array $response): void { + public function remember(string $scope, string $key, array $response, string $binding): void { $folder = $this->folder(create: true); - $content = (string)json_encode(['expiresAt' => $this->time->getTime() + self::TTL, 'response' => $response]); + $content = (string)json_encode(['expiresAt' => $this->time->getTime() + self::TTL, 'binding' => $binding, 'response' => $response]); $name = $this->name(scope: $scope, key: $key); if ($folder->fileExists($name) === true) { $folder->getFile($name)->putContent($content); @@ -155,7 +237,7 @@ public function purge(): int { * * @param ISimpleFile $file The file. * - * @return array{expiresAt: int, response: array}|null The entry. + * @return array{expiresAt: int, binding: string, response: array}|null The entry. */ private function read(ISimpleFile $file): ?array { $entry = json_decode($file->getContent(), true); @@ -163,9 +245,45 @@ private function read(ISimpleFile $file): ?array { return null; } - return ['expiresAt' => $entry['expiresAt'], 'response' => $entry['response']]; + // An entry without a binding (written before bindings) matches nobody. + return ['expiresAt' => $entry['expiresAt'], 'binding' => (string)($entry['binding'] ?? ''), 'response' => $entry['response']]; }//end read() + /** + * A value with every map's keys in order, so equal payloads hash equally. + * + * @param mixed $value The value. + * + * @return mixed The value, keys sorted at every level. + */ + private function canonical(mixed $value): mixed { + if (is_array($value) === false) { + return $value; + } + + foreach ($value as $name => $item) { + $value[$name] = $this->canonical(value: $item); + } + + if (array_is_list($value) === false) { + ksort($value); + } + + return $value; + }//end canonical() + + /** + * The lock path for a scope and key: the file's own hash, under this app. + * + * @param string $scope The form scope. + * @param string $key The key. + * + * @return string The lock path. + */ + private function lockPath(string $scope, string $key): string { + return 'openregister/form-submit-key/' . hash('sha256', $scope . "\0" . $key); + }//end lockPath() + /** * The file name for a scope and key: a hash, so a key never names a path. * diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index 1b86410575..63965d9c07 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -156,7 +156,7 @@ public function __construct( * * @return array The answer: reference, id, receivedAt, confirmation, objects. * - * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 503 try again later. + * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 409 key taken or in use, 503 try again later. * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference */ @@ -249,7 +249,7 @@ public function saveDraft( * * @return array The answer: reference, id, receivedAt, confirmation, objects. * - * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 503 try again later. + * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 409 key taken or in use, 503 try again later. * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-writing-several-objects-must-commit-all-or-none */ @@ -263,28 +263,76 @@ public function submitAll( ?DraftOwner $owner = null, ): array { $key = trim((string)$idempotencyKey); + if ($key === '') { + return $this->submitOnce(writes: $writes, payload: $payload, subject: $subject, scope: $scope, draftId: $draftId, owner: $owner); + } + $keyScope = $this->keyScope(scope: $scope, writes: $writes); - if ($key !== '') { - $remembered = $this->keys->find(scope: $keyScope, key: $key); + $binding = $this->keys->bindingFor(subject: $this->submitterOf(subject: $subject, owner: $owner), payload: $payload); + + // Reserve the key for the length of the submit: a parallel retry with + // the same key is refused (409) instead of writing a second object. + $this->keys->reserve(scope: $keyScope, key: $key); + try { + $remembered = $this->keys->find(scope: $keyScope, key: $key, binding: $binding); if ($remembered !== null) { /* * @var array $remembered */ return $remembered; } + + $answer = $this->submitOnce(writes: $writes, payload: $payload, subject: $subject, scope: $scope, draftId: $draftId, owner: $owner); + $this->keys->remember(scope: $keyScope, key: $key, response: $answer, binding: $binding); + } finally { + $this->keys->release(scope: $keyScope, key: $key); } + return $answer; + }//end submitAll() + + /** + * Write one submit and answer it, without any idempotency handling. + * + * @param array> $writes The writes, in order. + * @param array $payload What the submitter filled in. + * @param IUser|null $subject The signed-in subject; null for anonymous. + * @param string $scope The form id the tokens belong to. + * @param string|null $draftId The subject's draft to leave (one write only). + * @param DraftOwner|null $owner The draft's asserted owner. + * + * @return array The answer. + * + * @throws FormSubmitRefusedException 422, 403, 404 or 503. + */ + private function submitOnce(array $writes, array $payload, ?IUser $subject, string $scope, ?string $draftId, ?DraftOwner $owner): array { $plans = $this->plan(writes: $writes, payload: $payload, scope: $scope); [$created, $receivedAt] = $this->writeOrPromote(plans: $plans, subject: $subject, draftId: $draftId, owner: $owner); $this->uploads->claim(tokens: array_merge(...array_map(static fn (array $plan): array => $plan['tokens'], $plans))); - $answer = $this->answer(created: $created, receivedAt: $receivedAt); - if ($key !== '') { - $this->keys->remember(scope: $keyScope, key: $key, response: $answer); + + return $this->answer(created: $created, receivedAt: $receivedAt); + }//end submitOnce() + + /** + * Who the submit is by, as the idempotency binding reads it. + * + * @param IUser|null $subject The signed-in subject. + * @param DraftOwner|null $owner The asserted draft owner. + * + * @return string The user id, a scope value, or the anonymous marker. + */ + private function submitterOf(?IUser $subject, ?DraftOwner $owner): string { + if ($subject !== null) { + return 'user:' . $subject->getUID(); } - return $answer; - }//end submitAll() + if ($owner !== null && $owner->isScoped() === true) { + return 'scope:' . $owner->value(); + } + + return 'anonymous'; + }//end submitterOf() /** * Validate and write a fresh submit, or promote a draft. diff --git a/tests/Unit/Service/Form/Fakes/MemoryLockingProvider.php b/tests/Unit/Service/Form/Fakes/MemoryLockingProvider.php new file mode 100644 index 0000000000..bf1795305b --- /dev/null +++ b/tests/Unit/Service/Form/Fakes/MemoryLockingProvider.php @@ -0,0 +1,79 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Form\Fakes; + +use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; + +/** + * Exclusive locks by path; a second acquire of a held path is a LockedException. + */ +class MemoryLockingProvider implements ILockingProvider { + + /** + * Paths held exclusively. + * + * @var array + */ + public array $held = []; + + /** + * Whether a path is locked. + * + * @param string $path The path. + * @param int $type The lock type. + */ + public function isLocked(string $path, int $type): bool { + return isset($this->held[$path]); + }//end isLocked() + + /** + * Take a lock, or fail when it is held. + * + * @param string $path The path. + * @param int $type The lock type. + * @param string|null $readablePath The readable path. + */ + public function acquireLock(string $path, int $type, ?string $readablePath = null): void { + if (isset($this->held[$path]) === true) { + throw new LockedException($path); + } + + $this->held[$path] = true; + }//end acquireLock() + + /** + * Release a lock. + * + * @param string $path The path. + * @param int $type The lock type. + */ + public function releaseLock(string $path, int $type): void { + unset($this->held[$path]); + }//end releaseLock() + + /** + * Change a lock's type (unused). + * + * @param string $path The path. + * @param int $targetType The type. + */ + public function changeLock(string $path, int $targetType): void { + }//end changeLock() + + /** + * Release every lock. + */ + public function releaseAll(): void { + $this->held = []; + }//end releaseAll() +}//end class diff --git a/tests/Unit/Service/Form/FormIdempotencyStoreTest.php b/tests/Unit/Service/Form/FormIdempotencyStoreTest.php index 1e149a94a6..5cc4eb9632 100644 --- a/tests/Unit/Service/Form/FormIdempotencyStoreTest.php +++ b/tests/Unit/Service/Form/FormIdempotencyStoreTest.php @@ -14,8 +14,11 @@ namespace OCA\OpenRegister\Tests\Unit\Service\Form; use OCA\OpenRegister\Service\Form\FormIdempotencyStore; +use OCA\OpenRegister\Exception\FormSubmitRefusedException; use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryAppData; +use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryLockingProvider; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IL10N; use PHPUnit\Framework\TestCase; /** @@ -31,18 +34,23 @@ class FormIdempotencyStoreTest extends TestCase { private FormIdempotencyStore $store; + private MemoryLockingProvider $locks; + protected function setUp(): void { $this->appData = new MemoryAppData(); $time = $this->createMock(ITimeFactory::class); $time->method('getTime')->willReturnCallback(fn (): int => $this->now); - $this->store = new FormIdempotencyStore(appData: $this->appData, time: $time); + $this->locks = new MemoryLockingProvider(); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $this->store = new FormIdempotencyStore(appData: $this->appData, time: $time, locks: $this->locks, l10n: $l10n); }//end setUp() /** * Nothing remembered: nothing found. */ public function testAnUnknownKeyFindsNothing(): void { - $this->assertNull($this->store->find(scope: 'form-1', key: 'k1')); + $this->assertNull($this->store->find(scope: 'form-1', key: 'k1', binding: 'b')); }//end testAnUnknownKeyFindsNothing() /** @@ -50,20 +58,20 @@ public function testAnUnknownKeyFindsNothing(): void { */ public function testARememberedAnswerIsFoundWithinTheWindow(): void { $answer = ['reference' => '2026-0412', 'id' => 'uuid-7']; - $this->store->remember(scope: 'form-1', key: 'k1', response: $answer); + $this->store->remember(scope: 'form-1', key: 'k1', response: $answer, binding: 'b'); $this->now += 86_399; - $this->assertSame($answer, $this->store->find(scope: 'form-1', key: 'k1')); + $this->assertSame($answer, $this->store->find(scope: 'form-1', key: 'k1', binding: 'b')); }//end testARememberedAnswerIsFoundWithinTheWindow() /** * After 24 hours the key is free again, and the expired entry is gone. */ public function testAnExpiredAnswerIsNotFound(): void { - $this->store->remember(scope: 'form-1', key: 'k1', response: ['id' => 'uuid-7']); + $this->store->remember(scope: 'form-1', key: 'k1', response: ['id' => 'uuid-7'], binding: 'b'); $this->now += 86_401; - $this->assertNull($this->store->find(scope: 'form-1', key: 'k1')); + $this->assertNull($this->store->find(scope: 'form-1', key: 'k1', binding: 'b')); $this->assertSame([], $this->appData->folders[FormIdempotencyStore::FOLDER]->files); }//end testAnExpiredAnswerIsNotFound() @@ -71,22 +79,72 @@ public function testAnExpiredAnswerIsNotFound(): void { * The same key on another form is another submit. */ public function testKeysAreScopedPerForm(): void { - $this->store->remember(scope: 'form-1', key: 'k1', response: ['id' => 'uuid-7']); + $this->store->remember(scope: 'form-1', key: 'k1', response: ['id' => 'uuid-7'], binding: 'b'); - $this->assertNull($this->store->find(scope: 'form-2', key: 'k1')); + $this->assertNull($this->store->find(scope: 'form-2', key: 'k1', binding: 'b')); }//end testKeysAreScopedPerForm() + /** + * The same key from another subject or with another payload does not get the first answer. + */ + public function testAnotherBindingGetsNoAnswerOnlyAConflict(): void { + $mine = $this->store->bindingFor(subject: 'user:alice', payload: ['title' => 'a', 'n' => ['x' => 1, 'y' => 2]]); + $this->store->remember(scope: 'form-1', key: 'k1', response: ['reference' => 'secret'], binding: $mine); + + // The same subject and an equal payload (keys in another order) is a repeat. + $again = $this->store->bindingFor(subject: 'user:alice', payload: ['n' => ['y' => 2, 'x' => 1], 'title' => 'a']); + $this->assertSame(['reference' => 'secret'], $this->store->find(scope: 'form-1', key: 'k1', binding: $again)); + + foreach ( + [ + $this->store->bindingFor(subject: 'user:bob', payload: ['title' => 'a', 'n' => ['x' => 1, 'y' => 2]]), + $this->store->bindingFor(subject: 'anonymous', payload: ['title' => 'a', 'n' => ['x' => 1, 'y' => 2]]), + $this->store->bindingFor(subject: 'user:alice', payload: ['title' => 'changed']), + ] as $other + ) { + try { + $this->store->find(scope: 'form-1', key: 'k1', binding: $other); + $this->fail('A different binding received the first answer.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(409, $refused->getStatus()); + $this->assertStringNotContainsString('secret', $refused->getMessage()); + } + } + }//end testAnotherBindingGetsNoAnswerOnlyAConflict() + + /** + * A key being worked on is refused to a second request until it is released. + */ + public function testAReservedKeyRefusesASecondRequestUntilReleased(): void { + $this->store->reserve(scope: 'form-1', key: 'k1'); + + try { + $this->store->reserve(scope: 'form-1', key: 'k1'); + $this->fail('A key in use was reserved twice.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(409, $refused->getStatus()); + } + + // Another key and another form are not held up. + $this->store->reserve(scope: 'form-1', key: 'k2'); + $this->store->reserve(scope: 'form-2', key: 'k1'); + + $this->store->release(scope: 'form-1', key: 'k1'); + $this->store->reserve(scope: 'form-1', key: 'k1'); + $this->addToAssertionCount(1); + }//end testAReservedKeyRefusesASecondRequestUntilReleased() + /** * The purge deletes only expired entries and counts them. */ public function testThePurgeCountsWhatItDeleted(): void { - $this->store->remember(scope: 'f', key: 'old', response: ['id' => 'a']); + $this->store->remember(scope: 'f', key: 'old', response: ['id' => 'a'], binding: 'b'); $this->now += 50_000; - $this->store->remember(scope: 'f', key: 'new', response: ['id' => 'b']); + $this->store->remember(scope: 'f', key: 'new', response: ['id' => 'b'], binding: 'b'); $this->now += 40_000; $this->assertSame(1, $this->store->purge()); - $this->assertSame(['id' => 'b'], $this->store->find(scope: 'f', key: 'new')); + $this->assertSame(['id' => 'b'], $this->store->find(scope: 'f', key: 'new', binding: 'b')); $this->assertSame(0, $this->store->purge()); }//end testThePurgeCountsWhatItDeleted() diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php index e4058b15c8..42f7edc8e6 100644 --- a/tests/Unit/Service/Form/FormSubmitServiceTest.php +++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php @@ -32,6 +32,7 @@ use OCA\OpenRegister\Service\Object\ValidateObject; use OCA\OpenRegister\Service\ObjectService; use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryAppData; +use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryLockingProvider; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\Event; @@ -137,6 +138,18 @@ class FormSubmitServiceTest extends TestCase { */ private array $dispatched = []; + /** + * The locks the idempotency store holds. + */ + private MemoryLockingProvider $locks; + + /** + * Run inside the next save, to simulate a parallel request. + * + * @var callable|null + */ + private $duringSave = null; + protected function setUp(): void { $this->schemaBySlug = [ 'case' => $this->schema( @@ -231,6 +244,7 @@ function (): string { ); $appData = new MemoryAppData(); + $this->locks = new MemoryLockingProvider(); $this->uploads = new FormUploadStore(appData: $appData, time: $time, temp: $temp, rules: new FilePropertyRules(), l10n: $l10n); $this->service = new FormSubmitService( @@ -239,7 +253,7 @@ function (): string { validator: $validateObject, objects: $this->objects, findings: new FormPayloadFindings(), - keys: new FormIdempotencyStore(appData: $appData, time: $time), + keys: new FormIdempotencyStore(appData: $appData, time: $time, locks: $this->locks, l10n: $l10n), uploads: $this->uploads, l10n: $l10n, logger: $this->createMock(LoggerInterface::class), @@ -330,6 +344,12 @@ private function fakeSave( ?string $_owner = null, ): ObjectEntity { $this->assertInstanceOf(Schema::class, $schema); + if ($this->duringSave !== null) { + $hook = $this->duringSave; + $this->duringSave = null; + $hook(); + } + $this->saves[] = ['object' => $object, 'schema' => $schema->getSlug(), 'rbac' => $_rbac, 'user' => $currentUser, 'unowned' => $_unowned, 'files' => $uploadedFiles, 'uuid' => $uuid, 'owner' => $_owner]; if (isset($this->refuseSave[$schema->getSlug()]) === true) { throw $this->refuseSave[$schema->getSlug()]; @@ -457,6 +477,73 @@ public function testARepeatedIdempotencyKeyRepeatsTheAnswer(): void { $this->assertCount(2, $this->saves); }//end testARepeatedIdempotencyKeyRepeatsTheAnswer() + /** + * The key is client-chosen: another user, or another payload, with the same key never gets the first answer. + */ + public function testAnIdempotencyKeyDoesNotHandTheAnswerToSomebodyElse(): void { + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $alice = $this->createMock(IUser::class); + $alice->method('getUID')->willReturn('alice'); + $bob = $this->createMock(IUser::class); + $bob->method('getUID')->willReturn('bob'); + $payload = ['title' => 'a', 'caseType' => 'b']; + + $first = $this->service->submit(destination: $destination, mapping: null, payload: $payload, subject: $alice, idempotencyKey: 'k1', scope: 'form-1'); + + foreach ([[$bob, $payload], [null, $payload], [$alice, ['title' => 'other', 'caseType' => 'b']]] as [$subject, $body]) { + try { + $this->service->submit(destination: $destination, mapping: null, payload: $body, subject: $subject, idempotencyKey: 'k1', scope: 'form-1'); + $this->fail('The key handed over the first answer.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(409, $refused->getStatus()); + $this->assertStringNotContainsString((string)$first['reference'], $refused->getMessage()); + } + } + + $this->assertCount(1, $this->saves); + $this->assertSame($first, $this->service->submit(destination: $destination, mapping: null, payload: $payload, subject: $alice, idempotencyKey: 'k1', scope: 'form-1')); + }//end testAnIdempotencyKeyDoesNotHandTheAnswerToSomebodyElse() + + /** + * Two submits in flight with one key create one object; the key is free again afterwards. + */ + public function testTwoConcurrentSubmitsWithOneKeyCreateOneObject(): void { + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $payload = ['title' => 'a', 'caseType' => 'b']; + $status = null; + // While the first submit is writing, the same request arrives again. + $this->duringSave = function () use ($destination, $payload, &$status): void { + try { + $this->service->submit(destination: $destination, mapping: null, payload: $payload, idempotencyKey: 'k1', scope: 'form-1'); + } catch (FormSubmitRefusedException $refused) { + $status = $refused->getStatus(); + } + }; + + $first = $this->service->submit(destination: $destination, mapping: null, payload: $payload, idempotencyKey: 'k1', scope: 'form-1'); + + $this->assertSame(409, $status); + $this->assertCount(1, $this->saves); + $this->assertSame([], $this->locks->held); + $this->assertSame($first, $this->service->submit(destination: $destination, mapping: null, payload: $payload, idempotencyKey: 'k1', scope: 'form-1')); + }//end testTwoConcurrentSubmitsWithOneKeyCreateOneObject() + + /** + * A refused submit releases its key, so the corrected retry is not locked out. + */ + public function testARefusedSubmitReleasesItsKey(): void { + try { + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a'], idempotencyKey: 'k1', scope: 'form-1'); + $this->fail('An incomplete submit was accepted.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(422, $refused->getStatus()); + } + + $this->assertSame([], $this->locks->held); + $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b'], idempotencyKey: 'k1', scope: 'form-1'); + $this->assertCount(1, $this->saves); + }//end testARefusedSubmitReleasesItsKey() + /** * Every write is validated before the first: an invalid second write means no write at all. */ From f990f3dd525b4c6390d584c96b9948f2e5b241c1 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:50:15 +0200 Subject: [PATCH 09/37] =?UTF-8?q?fix(review):=20#4580=20eng-f5=20=E2=80=94?= =?UTF-8?q?=20Draft=20cleanup=20deletes=20without=20re-checking=20the=20dr?= =?UTF-8?q?aft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove() now reads the object again (ObjectService::find, RBAC off, no render, no audit read) and deletes only while it is still a draft last changed before the cutoff the list was made with; a draft sent or touched since is counted as refused and logged at info level, an object that cannot be read again is left alone. The cutoff is passed through walk() and removeWithin(). DraftExpiryTest gets a find() fake and a test with a draft sent after the list. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660461 Sweep: DraftExpiry::remove has one caller; DraftExpiryJob and DraftsExpiredCommand only call sweep()/preview(). DraftExpiryJobTest gets the same find() fake (it builds DraftExpiry itself). Check: phpunit tests/Unit/BackgroundJob + DraftsExpiredCommandTest + DraftExpiryTest OK 338 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Object/DraftExpiry.php | 49 ++++++++++++--- .../Unit/BackgroundJob/DraftExpiryJobTest.php | 4 ++ tests/Unit/Service/Object/DraftExpiryTest.php | 61 +++++++++++++++++++ 3 files changed, 106 insertions(+), 8 deletions(-) diff --git a/lib/Service/Object/DraftExpiry.php b/lib/Service/Object/DraftExpiry.php index da142535b4..48b279a56c 100644 --- a/lib/Service/Object/DraftExpiry.php +++ b/lib/Service/Object/DraftExpiry.php @@ -173,12 +173,19 @@ private function walk(DateTimeImmutable $now, int $budget): array { } $counts['schemas']++; - $expired = $this->expiredIn(register: $register, schema: $schema, cutoff: $now->modify('-' . $days . ' days')); + $cutoff = $now->modify('-' . $days . ' days'); + $expired = $this->expiredIn(register: $register, schema: $schema, cutoff: $cutoff); if ($expired === []) { continue; } - $row = $this->removeWithin(register: $register, schema: $schema, expired: $expired, budget: ($budget - $counts['removed'] - $counts['refused'])); + $row = $this->removeWithin( + register: $register, + schema: $schema, + expired: $expired, + budget: ($budget - $counts['removed'] - $counts['refused']), + cutoff: $cutoff + ); $row['lifetimeDays'] = $days; $counts['perSchema'][] = $row; foreach (['expired', 'removed', 'refused', 'deferred'] as $key) { @@ -211,11 +218,12 @@ private function holdsDrafts(Schema $schema): bool { * @param Schema $schema The schema. * @param array $expired The uuids of its expired drafts. * @param int $budget How many delete attempts are left in this run. + * @param DateTimeImmutable $cutoff The cutoff the list was made with, asked again just before each delete. * * @return array{register: string, schema: string, lifetimeDays: int, expired: int, removed: int, refused: int, deferred: int} * The counts of this pair; the caller fills in the lifetime. */ - private function removeWithin(Register $register, Schema $schema, array $expired, int $budget): array { + private function removeWithin(Register $register, Schema $schema, array $expired, int $budget, DateTimeImmutable $cutoff): array { $row = [ 'register' => (string)($register->getSlug() ?? $register->getId()), 'schema' => (string)($schema->getSlug() ?? $schema->getId()), @@ -232,7 +240,7 @@ private function removeWithin(Register $register, Schema $schema, array $expired continue; } - if ($this->remove(register: $register, schema: $schema, uuid: $uuid) === true) { + if ($this->remove(register: $register, schema: $schema, uuid: $uuid, cutoff: $cutoff) === true) { $row['removed']++; continue; } @@ -351,14 +359,39 @@ private function isExpired(mixed $candidate, DateTimeImmutable $cutoff): bool { /** * Delete one expired draft through the ordinary delete path. * - * @param Register $register The register. - * @param Schema $schema The schema. - * @param string $uuid The draft. + * The list was read before the first delete, and a sweep can take a + * while: the owner may have sent the draft since. The object is read + * again here and deleted only while it is still a draft last changed + * before the cutoff, so a submission that just got its receipt is never + * removed. An object that cannot be read again is left alone. + * + * @param Register $register The register. + * @param Schema $schema The schema. + * @param string $uuid The draft. + * @param DateTimeImmutable $cutoff Drafts last changed before this moment are expired. * * @return bool True when it was removed. */ - private function remove(Register $register, Schema $schema, string $uuid): bool { + private function remove(Register $register, Schema $schema, string $uuid, DateTimeImmutable $cutoff): bool { try { + $current = $this->objects->find( + id: $uuid, + register: $register, + schema: $schema, + _rbac: false, + _multitenancy: false, + _render: false, + _audit: false + ); + if ($this->isExpired(candidate: $current, cutoff: $cutoff) === false) { + $this->logger->info( + message: '[DraftExpiry] A draft was not removed: it is no longer an expired draft', + context: ['register' => $register->getSlug(), 'schema' => $schema->getSlug(), 'uuid' => $uuid] + ); + + return false; + } + // RBAC off: a background job has no session, and the lifetime the // schema declares is the authorisation. Everything else the delete // path guards (append-only, referential integrity, files) stays diff --git a/tests/Unit/BackgroundJob/DraftExpiryJobTest.php b/tests/Unit/BackgroundJob/DraftExpiryJobTest.php index 4bcfb43a3a..3fc71be3d4 100644 --- a/tests/Unit/BackgroundJob/DraftExpiryJobTest.php +++ b/tests/Unit/BackgroundJob/DraftExpiryJobTest.php @@ -155,6 +155,10 @@ public function testTheDeclaredJobRemovesAnExpiredDraft(): void { $objects->method('searchObjects')->willReturnCallback( static fn (array $query = []): array => $query['@self']['schema'] === '1' ? [$expired, $fresh] : [] ); + // The sweep reads each object again just before deleting it. + $objects->method('find')->willReturnCallback( + static fn (string|int $id): ?ObjectEntity => (array_values(array_filter([$expired, $fresh], static fn (ObjectEntity $entity): bool => $entity->getUuid() === (string)$id))[0] ?? null) + ); $objects->method('deleteObject')->willReturnCallback( function (string $uuid) use (&$deleted, &$actors): bool { $deleted[] = $uuid; diff --git a/tests/Unit/Service/Object/DraftExpiryTest.php b/tests/Unit/Service/Object/DraftExpiryTest.php index d9b2dd45c2..800c76802c 100644 --- a/tests/Unit/Service/Object/DraftExpiryTest.php +++ b/tests/Unit/Service/Object/DraftExpiryTest.php @@ -25,6 +25,7 @@ use OCA\OpenRegister\Service\Object\DraftExpiry; use OCA\OpenRegister\Service\Object\DraftRemovalRecord; use OCA\OpenRegister\Service\ObjectService; +use OCP\AppFramework\Db\DoesNotExistException; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use RuntimeException; @@ -85,6 +86,20 @@ class DraftExpiryTest extends TestCase { */ private array $schemas = []; + /** + * Uuids read again just before their delete. + * + * @var array + */ + private array $finds = []; + + /** + * What the second read answers for a uuid, instead of the listed object (a draft sent in between). + * + * @var array + */ + private array $replaced = []; + /** * Registers the mapper lists. * @@ -111,6 +126,24 @@ function (array $query = [], bool $_rbac = true, bool $_multitenancy = true): ar return array_slice(($this->stored[$key] ?? []), (int)$query['_offset'], (int)$query['_limit']); } ); + $objects->method('find')->willReturnCallback( + function (string|int $id, ...$arguments): ?ObjectEntity { + $this->finds[] = (string)$id; + if (isset($this->replaced[(string)$id]) === true) { + return $this->replaced[(string)$id]; + } + + foreach ($this->stored as $list) { + foreach ($list as $entity) { + if ($entity instanceof ObjectEntity && $entity->getUuid() === (string)$id) { + return $entity; + } + } + } + + throw new DoesNotExistException('gone'); + } + ); $objects->method('deleteObject')->willReturnCallback( function (string $uuid, $register = null, $schema = null, bool $_rbac = true, bool $_multitenancy = true, bool $_retentionSweep = false, $currentUser = null, bool $permanent = false): bool { $this->deletes[] = [ @@ -442,6 +475,34 @@ public function testARefusedDeleteIsCountedAndTheSweepCarriesOn(): void { $this->assertSame(['[DraftExpiry] An expired draft was not removed'], $this->warnings); }//end testARefusedDeleteIsCountedAndTheSweepCarriesOn() + /** + * A draft the owner sent between the list and the delete is not deleted; a draft touched since is not either. + */ + public function testADraftSentAfterTheListIsNotDeleted(): void { + $this->schema(id: 1, slug: 'ticket', configuration: ['draftLifetimeDays' => 7]); + $this->register(id: 10, slug: 'service', schemaIds: [1]); + $this->object(where: '10/1', uuid: 'sent', status: 'draft', updated: '2026-09-01T00:00:00+00:00'); + $this->object(where: '10/1', uuid: 'touched', status: 'draft', updated: '2026-09-01T00:00:00+00:00'); + $this->object(where: '10/1', uuid: 'stale', status: 'draft', updated: '2026-09-01T00:00:00+00:00'); + + $sent = new ObjectEntity(); + $sent->setUuid('sent'); + $sent->setStatus('active'); + $sent->setUpdated(new DateTime('2026-10-12T02:59:00+00:00')); + $this->replaced['sent'] = $sent; + $touched = new ObjectEntity(); + $touched->setUuid('touched'); + $touched->setStatus('draft'); + $touched->setUpdated(new DateTime('2026-10-12T02:59:00+00:00')); + $this->replaced['touched'] = $touched; + + $counts = $this->expiry->sweep(now: $this->now); + + $this->assertSame(['sent', 'touched', 'stale'], $this->finds); + $this->assertSame(['stale'], array_column($this->deletes, 'uuid')); + $this->assertSame(['schemas' => 1, 'expired' => 3, 'removed' => 1, 'refused' => 2, 'deferred' => 0], $this->totals(counts: $counts)); + }//end testADraftSentAfterTheListIsNotDeleted() + /** * Every page is read before the first delete, so no draft is skipped by a shifting page. */ From 33f3e3fb5701ce69e83323d6e8d9d6d241e23dff Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:51:15 +0200 Subject: [PATCH 10/37] =?UTF-8?q?fix(review):=20#4580=20eng-f7=20=E2=80=94?= =?UTF-8?q?=20Dependent=20form=20reported=20unpublished=20when=20nothing?= =?UTF-8?q?=20was=20unpublished?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unpublish() answers 'unpublish-unavailable' (and logs a warning) for a FormDependent registered without a closure, instead of falling through to notify the author and answer 'unpublished'. The outcome is already listed per form in the schema save response ('affectedForms'), so the save surfaces it. FormDependentsCheckTest covers the null closure. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660498 Sweep: FormDependent::$unpublish is read only in FormDependentsCheck::unpublish. Check: phpunit tests/Unit/Service/Form/FormDependentsCheckTest.php OK 5 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Form/FormDependentsCheck.php | 17 +++++++--- .../Service/Form/FormDependentsCheckTest.php | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/lib/Service/Form/FormDependentsCheck.php b/lib/Service/Form/FormDependentsCheck.php index 321dc195a8..28304408a4 100644 --- a/lib/Service/Form/FormDependentsCheck.php +++ b/lib/Service/Form/FormDependentsCheck.php @@ -151,13 +151,22 @@ public function apply(array $assessment): array { * @param string $schema The schema slug. * @param array> $findings Why. * - * @return string `unpublished`, or `unpublish-failed` when the owning app could not. + * @return string `unpublished`, `unpublish-failed` when the owning app could not, or + * `unpublish-unavailable` when it registered the form without a way to unpublish it. */ private function unpublish(FormDependent $form, string $schema, array $findings): string { + if ($form->unpublish === null) { + // Nothing was unpublished: say so, and do not tell the author it was. + $this->logger->warning( + message: '[FormDependentsCheck] A form broken by a schema change has no way to be unpublished; it stays published', + context: ['form' => $form->id, 'app' => $form->app, 'schema' => $schema] + ); + + return 'unpublish-unavailable'; + } + try { - if ($form->unpublish !== null) { - ($form->unpublish)(); - } + ($form->unpublish)(); } catch (Throwable $exception) { $this->logger->error( message: '[FormDependentsCheck] An owning app could not unpublish a form broken by a schema change', diff --git a/tests/Unit/Service/Form/FormDependentsCheckTest.php b/tests/Unit/Service/Form/FormDependentsCheckTest.php index ae80ae64c0..8dbc87d721 100644 --- a/tests/Unit/Service/Form/FormDependentsCheckTest.php +++ b/tests/Unit/Service/Form/FormDependentsCheckTest.php @@ -62,6 +62,11 @@ class FormDependentsCheckTest extends TestCase { private FormDependentsCheck $check; + /** + * Whether the owning app also registers a form without an unpublish closure. + */ + private bool $withClosureless = false; + protected function setUp(): void { $l10n = $this->createMock(IL10N::class); $l10n->method('t')->willReturnCallback( @@ -97,6 +102,17 @@ function (Event $event): void { } ) ); + if ($this->withClosureless === true) { + $event->addForm( + new FormDependent( + id: 'form-c', + app: 'pipelinq', + title: 'Zonder sluiter', + author: 'carol', + mapping: ['fields' => [['field' => 'onderwerp', 'property' => 'title']]] + ) + ); + } } ); @@ -191,6 +207,21 @@ public function testANewRequiredPropertyUnpublishesTheFormThatLacksIt(): void { $this->assertSame('Contact opnemen', $this->sent[0]['parameters']['formTitle']); }//end testANewRequiredPropertyUnpublishesTheFormThatLacksIt() + /** + * A form its owning app registered without an unpublish closure is not reported unpublished, and its author is not told so. + */ + public function testAFormWithoutAnUnpublishClosureIsNotReportedUnpublished(): void { + $this->withClosureless = true; + $assessment = $this->check->assess(schema: $this->ticketWithRequiredPriority()); + + $applied = $this->check->apply(assessment: $assessment); + + $outcomes = array_column($applied, 'outcome', 'id'); + $this->assertSame('unpublish-unavailable', $outcomes['form-c']); + $this->assertSame('unpublished', $outcomes['form-a']); + $this->assertSame(['alice'], array_column($this->sent, 'user'), 'only the author of a form that was unpublished is told'); + }//end testAFormWithoutAnUnpublishClosureIsNotReportedUnpublished() + /** * An owning app set to refuse blocks the schema save, and nothing is unpublished. */ From 1f4eafbd73fd38568a36f0a96e3f5667811772b5 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:52:04 +0200 Subject: [PATCH 11/37] =?UTF-8?q?fix(review):=20#4580=20surf-f6=20?= =?UTF-8?q?=E2=80=94=20Dependent-form=20re-check=20runs=20on=20one=20schem?= =?UTF-8?q?a=20write=20path=20only=20(logging=20part)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formDependentsCheck() now logs at warning level when the container cannot build the check (it used to return null in silence, which skipped an owning app's refuse setting without a trace), and the lookup happens only for an update that carries properties or required. Test: testAFailingDependentFormCheckIsLogged. Not done here: running the check on the other schema write paths (configuration importer, SchemaImportController, SchemaTool, SchemasToolProvider, SchemaService), because what 'refuse' means for a write that has no response to refuse is the author's call. See the hand-back note. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660903 Check: phpunit tests/Unit/Controller/SchemasControllerTest.php OK 83 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Controller/SchemasController.php | 22 +++++++++++++++++-- .../Unit/Controller/SchemasControllerTest.php | 21 ++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php index b49609331a..5afa4fd5fe 100644 --- a/lib/Controller/SchemasController.php +++ b/lib/Controller/SchemasController.php @@ -1109,8 +1109,12 @@ public function update(int $id): JSONResponse { * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it */ private function assessDependentForms(Schema $existing, array $data): ?array { + if (isset($data['properties']) === false && isset($data['required']) === false) { + return null; + } + $check = $this->formDependentsCheck(); - if ($check === null || (isset($data['properties']) === false && isset($data['required']) === false)) { + if ($check === null) { return null; } @@ -1129,12 +1133,21 @@ private function assessDependentForms(Schema $existing, array $data): ?array { /** * The dependent-form check, resolved lazily like the audit service. * + * A check that cannot be built is logged at warning level: the save then + * goes ahead without judging the published forms, and that must leave a trace + * (an owning app's `refuse` setting is skipped with it). + * * @return FormDependentsCheck|null The check, or null when it cannot be built. */ private function formDependentsCheck(): ?FormDependentsCheck { try { $check = $this->container->get(FormDependentsCheck::class); - } catch (\Throwable) { + } catch (\Throwable $e) { + $this->logger->warning( + message: '[SchemasController] The dependent-form check could not be built; the schema save did not re-check the published forms', + context: ['error' => $e->getMessage()] + ); + return null; } @@ -1142,6 +1155,11 @@ private function formDependentsCheck(): ?FormDependentsCheck { return $check; } + $this->logger->warning( + message: '[SchemasController] The dependent-form check could not be built; the schema save did not re-check the published forms', + context: ['error' => 'the container did not return a FormDependentsCheck'] + ); + return null; }//end formDependentsCheck() diff --git a/tests/Unit/Controller/SchemasControllerTest.php b/tests/Unit/Controller/SchemasControllerTest.php index 033c8cfe92..3211b22722 100644 --- a/tests/Unit/Controller/SchemasControllerTest.php +++ b/tests/Unit/Controller/SchemasControllerTest.php @@ -369,6 +369,27 @@ public function testUpdateListsTheFormsItUnpublished(): void { $this->assertSame([['id' => 'form-a', 'outcome' => 'unpublished']], $result->getData()['affectedForms']); }//end testUpdateListsTheFormsItUnpublished() + /** + * A dependent-form check that cannot be built is logged, not read as "no forms affected". + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + public function testAFailingDependentFormCheckIsLogged(): void { + $schema = $this->createRealSchema(1, 'Ticket'); + $this->schemaMapper->method('find')->willReturn($schema); + $this->schemaMapper->method('updateFromArray')->willReturn($schema); + $this->request->method('getParams')->willReturn(['required' => ['title', 'priority']]); + $this->schemaVersioningService->method('classify')->willReturn(new \OCA\OpenRegister\Service\Schema\SchemaChangeSet([], 'none', 'none')); + $this->formCheck = null; + $this->logger->expects($this->atLeastOnce())->method('warning') + ->with($this->stringContains('dependent-form check could not be built'), $this->anything()); + + $result = $this->controller->update(1); + + $this->assertSame(200, $result->getStatus()); + $this->assertArrayNotHasKey('affectedForms', $result->getData() instanceof Schema ? [] : (array)$result->getData()); + }//end testAFailingDependentFormCheckIsLogged() + public function testPatchDelegatesToUpdate(): void { $schema = $this->createRealSchema(1, 'Patched'); From 03e338eb67ce4c3072bf3484dedf173c27679349 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 17:52:43 +0200 Subject: [PATCH 12/37] =?UTF-8?q?fix(review):=20#4580=20data-f4=20?= =?UTF-8?q?=E2=80=94=20Forced=20re-import=20deletes=20local=20authorizatio?= =?UTF-8?q?n=20the=20file=20lacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under an administrator's decision only the authorization roots the incoming definition carries are decided; a block only the instance holds keeps its normal state (local is preserved and reported) instead of settling as UPSTREAM and being dropped, which left the schema default-open. New GuardedDescriptorMergeTest case under a ShippedAuthorizationDecision; the replace-when-shipped-has-it cases stay green (the test fails with the old roots($live, $incoming)). Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660343 Sweep: AuthorizationBlocks::roots() has this one caller. Check: phpunit tests/Unit/Service/ShippedBaseline + RegisterDescriptorServiceTest OK 63 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- .../GuardedDescriptorMerge.php | 7 +++-- .../GuardedDescriptorMergeTest.php | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/Service/ShippedBaseline/GuardedDescriptorMerge.php b/lib/Service/ShippedBaseline/GuardedDescriptorMerge.php index 45ba1b648b..2234425172 100644 --- a/lib/Service/ShippedBaseline/GuardedDescriptorMerge.php +++ b/lib/Service/ShippedBaseline/GuardedDescriptorMerge.php @@ -161,9 +161,12 @@ public function merge(array $baseline, array $live, array $incoming, array $deci } // An administrator's re-import is the explicit decision REQ-LCA-003 - // waits for, for every authorization block either side carries. + // waits for, for every authorization block the shipped definition + // carries. A block only the instance holds is not decided: the app + // shipped nothing to take, so the local policy stays (and is + // reported) instead of the schema turning default-open. if ($this->decidedVia() !== null) { - $decisions = array_values(array_unique(array_merge($decisions, $this->blocks->roots($live, $incoming)))); + $decisions = array_values(array_unique(array_merge($decisions, $this->blocks->roots($incoming)))); } $blocks = $this->blocks->settle(states: $states, decisions: $decisions); diff --git a/tests/Unit/Service/ShippedBaseline/GuardedDescriptorMergeTest.php b/tests/Unit/Service/ShippedBaseline/GuardedDescriptorMergeTest.php index 9fc2f4aa58..c40e117378 100644 --- a/tests/Unit/Service/ShippedBaseline/GuardedDescriptorMergeTest.php +++ b/tests/Unit/Service/ShippedBaseline/GuardedDescriptorMergeTest.php @@ -25,6 +25,7 @@ use OCA\OpenRegister\Service\ShippedBaseline\DescriptorParts; use OCA\OpenRegister\Service\ShippedBaseline\DivergenceComparator; use OCA\OpenRegister\Service\ShippedBaseline\GuardedDescriptorMerge; +use OCA\OpenRegister\Service\ShippedBaseline\ShippedAuthorizationDecision; use PHPUnit\Framework\TestCase; /** @@ -513,4 +514,31 @@ public function testAPropertyAuthorizationIsItsOwnBlock(): void { $this->assertSame(['properties.bsn.authorization'], array_column($result['conflicts'], 'path')); $this->assertSame('string', $result['merged']['properties']['bsn']['type']); }//end testAPropertyAuthorizationIsItsOwnBlock() + + /** + * Under an administrator's decision, a local block the shipped definition does not declare is kept, not dropped. + * + * @return void + * + * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md + */ + public function testADecisionKeepsALocalBlockTheShippedDefinitionLacks(): void { + $parts = new DescriptorParts(); + $decision = new ShippedAuthorizationDecision(); + $merge = new GuardedDescriptorMerge(parts: $parts, comparator: new DivergenceComparator(parts: $parts), decision: $decision); + $local = ['read' => ['staff'], 'update' => ['staff']]; + + $result = $decision->run( + via: 'import', + operation: static fn (): array => $merge->merge( + baseline: ['title' => 'T'], + live: ['title' => 'T', 'authorization' => $local], + incoming: ['title' => 'T2'] + ) + ); + + $this->assertSame('T2', $result['merged']['title']); + $this->assertEquals($local, $result['merged']['authorization']); + $this->assertSame([], $result['replacedLocal']); + }//end testADecisionKeepsALocalBlockTheShippedDefinitionLacks() }//end class From 363e6075c7124637ccc0bdfbb1ea3be5e831989a Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:04:24 +0200 Subject: [PATCH 13/37] =?UTF-8?q?fix(review):=20#4580=20data-f3=20?= =?UTF-8?q?=E2=80=94=20RBAC=20draft=20predicate=20ignores=20unhealed=20=5F?= =?UTF-8?q?status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MagicRbacHandler now takes the MagicMetadataColumnHealer (built first in MagicMapper::initializeHandlers) and an optional bare table name; reachableRowSqlFor() leaves the (_status IS NULL OR _status <> 'draft') term out when the table is known to lack _status, so a non-admin on a degraded table no longer fails with an unknown-column error. The table name is threaded through applyRbacFilters, buildRbacConditionsSql, buildRbacRowPredicateSql, buildRbacPredicateForAlias and buildRbacSqlTerms (all optional; a caller that names no table keeps the predicate, which is the narrower and safe answer) and passed by every caller that knows it: MagicSearchHandler (list, UNION conditions, single-object reads), the three MagicMapper single-object and scan sites, RelatedRowQueryApplier (the related table) and AggregationRunner (via MagicMapper::rbacRowPredicateSql(register:)). New test: testADegradedTableGetsNoDraftPredicate (missing: no _status, owner admit kept; healthy table and unnamed table: predicate kept). Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660319 Check: phpunit tests/Unit/Db tests/Unit/Service/Aggregation tests/Unit/Service/Query: 2407 tests, 49 errors, all 'PDOException: could not find driver' or 'OC\DB\Schema\Table is not loadable' (no database driver / Nextcloud server classes in this environment), none from the touched code; MagicRbacDraftVisibilityTest OK; phpcs on the touched lib files: no errors. Co-Authored-By: Claude Opus 5.5 --- lib/Db/MagicMapper.php | 47 ++++++++++------ lib/Db/MagicMapper/MagicRbacHandler.php | 56 +++++++++++++++---- lib/Db/MagicMapper/MagicSearchHandler.php | 30 +++++++--- lib/Service/Aggregation/AggregationRunner.php | 2 +- lib/Service/Query/RelatedRowQueryApplier.php | 6 +- .../MagicRbacDraftVisibilityTest.php | 35 +++++++++++- 6 files changed, 136 insertions(+), 40 deletions(-) diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php index e99068690b..b28f3db2c3 100644 --- a/lib/Db/MagicMapper.php +++ b/lib/Db/MagicMapper.php @@ -554,6 +554,16 @@ private function getFullTableName(string $tableName): string { * @return void */ private function initializeHandlers(): void { + // Built first: the RBAC handler asks it which metadata columns a + // degraded table still lacks, so the read predicate does not name a + // `_status` column the table could not be given. + $this->metadataColumnHealer = new MagicMetadataColumnHealer( + db: $this->db, + logger: $this->logger, + magicMapper: $this, + cache: $this->createMetadataColumnCache() + ); + $this->rbacHandler = new MagicRbacHandler( userSession: $this->userSession, groupManager: $this->groupManager, @@ -561,7 +571,8 @@ private function initializeHandlers(): void { appConfig: $this->appConfig, conditionMatcher: $this->container->get(\OCA\OpenRegister\Service\ConditionMatcher::class), container: $this->container, - logger: $this->logger + logger: $this->logger, + metadataColumns: $this->metadataColumnHealer ); $this->organizationHandler = new MagicOrganizationHandler( @@ -579,13 +590,6 @@ private function initializeHandlers(): void { // Built before the table handler, which asks it on every first use // of a table, and before the search handler, which asks it which // metadata columns a degraded table still lacks. - $this->metadataColumnHealer = new MagicMetadataColumnHealer( - db: $this->db, - logger: $this->logger, - magicMapper: $this, - cache: $this->createMetadataColumnCache() - ); - $this->tableHandler = new MagicTableHandler( db: $this->db, appConfig: $this->appConfig, @@ -6437,7 +6441,8 @@ public function findInRegisterSchemaTable( schema: $schema, _rbac: $_rbac, _multitenancy: $_multitenancy, - registerId: $register->getId() + registerId: $register->getId(), + tableName: $tableName ); } @@ -6794,7 +6799,8 @@ public function findAcrossAllMagicTables( schema: $schema, _rbac: $_rbac, _multitenancy: $_multitenancy, - registerId: $register?->getId() + registerId: $register?->getId(), + tableName: $tableName ); $accessResult = $accessQb->executeQuery(); @@ -7670,7 +7676,8 @@ private function scopeDeletedScanToCaller( schema: $schema, _rbac: $_rbac, _multitenancy: $_multitenancy, - registerId: $table['registerId'] + registerId: $table['registerId'], + tableName: self::TABLE_PREFIX . $table['registerId'] . '_' . $table['schemaId'] ); return true; @@ -7925,16 +7932,23 @@ public function findAllInRegisterSchemaTable( * the list and the raw query cannot drift apart. Columns are unqualified: * the query must read the magic table without an alias. * - * @param Schema $schema The schema of the table being read. - * @param string $action The action the rows are filtered for (default: 'read'). + * @param Schema $schema The schema of the table being read. + * @param string $action The action the rows are filtered for (default: 'read'). + * @param Register|null $register The register of the table, so a metadata column the table could not + * be given is left out of the predicate. Null keeps every predicate. * * @return string|null Null when no row restriction applies (an admin); otherwise * a predicate safe to AND into a WHERE, `1 = 0` for deny-all. * * @spec openspec/specs/rbac-scopes/spec.md */ - public function rbacRowPredicateSql(Schema $schema, string $action = 'read'): ?string { - return $this->rbacHandler->buildRbacRowPredicateSql(schema: $schema, action: $action); + public function rbacRowPredicateSql(Schema $schema, string $action = 'read', ?Register $register = null): ?string { + $tableName = null; + if ($register !== null) { + $tableName = $this->getTableNameForRegisterSchema(register: $register, schema: $schema); + } + + return $this->rbacHandler->buildRbacRowPredicateSql(schema: $schema, action: $action, tableName: $tableName); }//end rbacRowPredicateSql() /** @@ -10154,7 +10168,8 @@ public function filterReadableUuids(int $registerId, int $schemaId, array $uuids schema: $schema, _rbac: true, _multitenancy: true, - registerId: $registerId + registerId: $registerId, + tableName: $tableName ); $result = $qb->executeQuery(); diff --git a/lib/Db/MagicMapper/MagicRbacHandler.php b/lib/Db/MagicMapper/MagicRbacHandler.php index 5aeafe974d..f94304ebc7 100644 --- a/lib/Db/MagicMapper/MagicRbacHandler.php +++ b/lib/Db/MagicMapper/MagicRbacHandler.php @@ -130,6 +130,7 @@ class MagicRbacHandler { * bundled together; nullable so adding them is not a fatal at existing * construction sites. * @param DenyEnforcementMode|null $denyEnforcementMode The staging switch; nullable for the same reason. + * @param MagicMetadataColumnHealer|null $metadataColumns Knows which metadata columns a table that could not be healed still lacks. */ public function __construct( private readonly IUserSession $userSession, @@ -141,6 +142,7 @@ public function __construct( private readonly LoggerInterface $logger, private readonly ?RbacResolvers $resolvers = null, private readonly ?DenyEnforcementMode $denyEnforcementMode = null, + private readonly ?MagicMetadataColumnHealer $metadataColumns = null, ) { }//end __construct() @@ -328,6 +330,7 @@ private function ownerAdmitConditionsSql(array $userGroups, ?string $userId, str * @param string $action The action being filtered; a grant only counts when it carries that permission. * @param Schema|null $schema The schema, so its drafts can be kept to their owners (decision 180). * @param string $columnPrefix Table alias with its dot for the `_status` column, empty when unaliased. + * @param string|null $tableName The bare magic table; a `_status` column it lacks is left out of the draft predicate. * * @return string A SQL predicate true for rows this caller may reach. * @@ -341,6 +344,7 @@ private function reachableRowSqlFor( string $action, ?Schema $schema = null, string $columnPrefix = '', + ?string $tableName = null, ): string { $reachable = $this->objectScope()->notPrivateOrGrantedSql( authColumn: $columnName, @@ -354,14 +358,34 @@ private function reachableRowSqlFor( // object, unless the schema lets others see drafts. Added HERE, the one // predicate both list emitters use, so the owner admits beside it keep // the owner's own drafts in view on every path. + // A table that could not be given its `_status` column cannot hold a + // draft, and naming the column would fail the query for everyone but an + // administrator (REQ-MTH-002: such a table degrades quietly). $drafts = new DraftStatusPolicy(); - if ($schema !== null && $drafts->draftsVisibleToOthers(schema: $schema) === false) { + if ($schema !== null && $drafts->draftsVisibleToOthers(schema: $schema) === false && $this->statusColumnMissing(tableName: $tableName) === false) { $reachable = '(' . $reachable . ' AND ' . $drafts->notADraftSql(columnPrefix: $columnPrefix) . ')'; } return $reachable; }//end reachableRowSqlFor() + /** + * Whether a table is known to lack its `_status` column after an attempt to heal it. + * + * @param string|null $tableName Bare magic table name, null when unknown. + * + * @return bool True when the draft predicate must be left out for this table. + * + * @spec openspec/changes/magic-table-metadata-self-healing/specs/object-lifecycle/spec.md#requirement-a-table-that-cannot-be-healed-degrades-quietly-req-mth-002 + */ + private function statusColumnMissing(?string $tableName): bool { + if ($this->metadataColumns === null || $tableName === null || $tableName === '') { + return false; + } + + return in_array('_status', $this->metadataColumns->missingFor(tableName: $tableName), true); + }//end statusColumnMissing() + /** * The deny term for one list query, or `false` when nothing is visible. * @@ -504,6 +528,8 @@ private function denyFilterSqlFor( * @param IQueryBuilder $qb Query builder to modify * @param Schema $schema Schema with authorization configuration * @param string $action CRUD action to check (default: 'read') + * @param string|null $tableName The bare magic table being read, so a `_status` column it could not be + * given is left out of the draft predicate. Null keeps the predicate. * * @return void * @@ -517,6 +543,7 @@ public function applyRbacFilters( IQueryBuilder $qb, Schema $schema, string $action = 'read', + ?string $tableName = null, ): void { $user = $this->userSession->getUser(); $userId = $user?->getUID(); @@ -594,7 +621,8 @@ public function applyRbacFilters( userId: $userId, action: $action, schema: $schema, - columnPrefix: 't.' + columnPrefix: 't.', + tableName: $tableName ) ); @@ -1632,6 +1660,7 @@ private function deniesHere( * @param Schema $schema The schema of the rows the subquery reads. * @param string $alias The alias those rows carry in the subquery. * @param string $action The CRUD action being filtered. + * @param string|null $tableName The bare magic table of the aliased rows; a `_status` column it lacks is left out of the draft predicate. * * @return string A predicate, always non-empty, safe to AND into a WHERE. * @@ -1639,7 +1668,7 @@ private function deniesHere( * * @spec openspec/changes/query-related-schema-rows/specs/zoeken-filteren/spec.md */ - public function buildRbacPredicateForAlias(Schema $schema, string $alias, string $action = 'read'): string { + public function buildRbacPredicateForAlias(Schema $schema, string $alias, string $action = 'read', ?string $tableName = null): string { if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $alias) !== 1) { throw new InvalidArgumentException( sprintf('\'%s\' is not a table alias an access predicate may be built for.', $alias) @@ -1649,7 +1678,8 @@ public function buildRbacPredicateForAlias(Schema $schema, string $alias, string $result = $this->buildRbacConditionsSql( schema: $schema, action: $action, - columnPrefix: $alias . '.' + columnPrefix: $alias . '.', + tableName: $tableName ); if (($result['bypass'] ?? false) === true) { @@ -1677,6 +1707,7 @@ public function buildRbacPredicateForAlias(Schema $schema, string $alias, string * * @param Schema $schema The schema of the table being read. * @param string $action The action the rows are filtered for (default: 'read'). + * @param string|null $tableName The bare magic table being read; a `_status` column it lacks is left out of the draft predicate. * * @return string|null Null when no row restriction applies (an admin); otherwise * a non-empty predicate safe to AND into a WHERE, `1 = 0` @@ -1685,8 +1716,8 @@ public function buildRbacPredicateForAlias(Schema $schema, string $alias, string * * @spec openspec/specs/rbac-scopes/spec.md */ - public function buildRbacRowPredicateSql(Schema $schema, string $action = 'read'): ?string { - $result = $this->buildRbacConditionsSql(schema: $schema, action: $action); + public function buildRbacRowPredicateSql(Schema $schema, string $action = 'read', ?string $tableName = null): ?string { + $result = $this->buildRbacConditionsSql(schema: $schema, action: $action, tableName: $tableName); if (($result['bypass'] ?? false) === true) { return null; } @@ -1711,6 +1742,7 @@ public function buildRbacRowPredicateSql(Schema $schema, string $action = 'read' * UNION member. Unqualified, a column name still parses inside a * subquery and binds to the innermost FROM, which is silently wrong * the moment the related table does not carry it. + * @param string|null $tableName The bare magic table; a `_status` column it lacks is left out of the draft predicate. * * @return array{bypass: bool, conditions: string[]} Result with: * - 'bypass' => true means no filtering needed (user has full access) @@ -1718,7 +1750,7 @@ public function buildRbacRowPredicateSql(Schema $schema, string $action = 'read' * * @SuppressWarnings(PHPMD.NPathComplexity) Mirrors applyRbacFilters dispatch; carries the system-owner carve-out (openregister#1617). */ - public function buildRbacConditionsSql(Schema $schema, string $action = 'read', string $columnPrefix = ''): array { + public function buildRbacConditionsSql(Schema $schema, string $action = 'read', string $columnPrefix = '', ?string $tableName = null): array { $user = $this->userSession->getUser(); $userId = $user?->getUID(); @@ -1765,7 +1797,8 @@ public function buildRbacConditionsSql(Schema $schema, string $action = 'read', userId: $userId, userGroups: $userGroups, columnPrefix: $columnPrefix, - schema: $schema + schema: $schema, + tableName: $tableName ); if ($terms === null) { return ['bypass' => false, 'conditions' => []]; @@ -1840,6 +1873,7 @@ public function buildRbacConditionsSql(Schema $schema, string $action = 'read', * subquery and binds to the innermost FROM, which is silently wrong * the moment the related table does not carry it. * @param Schema|null $schema The schema, so its drafts can be kept to their owners (decision 180). + * @param string|null $tableName The bare magic table; a `_status` column it lacks is left out of the draft predicate. * * @return array{denyTerm: string|null, notPrivate: string, ownerAdmits: string[]}|null The term pieces, or null * when the deny term @@ -1851,7 +1885,8 @@ private function buildRbacSqlTerms( ?string $userId, array $userGroups, string $columnPrefix = '', - ?Schema $schema = null + ?Schema $schema = null, + ?string $tableName = null ): ?array { $denyTerm = $this->denyFilterSqlFor( authorization: $authorization, @@ -1872,7 +1907,8 @@ private function buildRbacSqlTerms( userId: $userId, action: $action, schema: $schema, - columnPrefix: $columnPrefix + columnPrefix: $columnPrefix, + tableName: $tableName ); $ownerAdmits = $this->ownerAdmitConditionsSql( diff --git a/lib/Db/MagicMapper/MagicSearchHandler.php b/lib/Db/MagicMapper/MagicSearchHandler.php index 68a16893d7..a6435a43c9 100644 --- a/lib/Db/MagicMapper/MagicSearchHandler.php +++ b/lib/Db/MagicMapper/MagicSearchHandler.php @@ -436,6 +436,8 @@ public function searchObjects( * @param bool $_rbac Whether to apply RBAC filtering. * @param bool $_multitenancy Whether to apply multitenancy filtering. * @param int|null $registerId The register of the table being read, for shared master data. + * @param string|null $tableName The bare magic table being read, so a `_status` column it could not be + * given is left out of the draft predicate. Null keeps the predicate. * * @return void * @@ -447,6 +449,7 @@ public function applyAccessControlToQuery( bool $_rbac = true, bool $_multitenancy = true, ?int $registerId = null, + ?string $tableName = null, ): void { // Mirror the list path: public schemas bypass multitenancy by default. // No explicit multitenancy request exists on the single-object read path, @@ -463,7 +466,8 @@ public function applyAccessControlToQuery( _rbac: $_rbac, _multitenancy: $resolvedMultitenancy, multitenancyExplicit: false, - registerId: $registerId + registerId: $registerId, + tableName: $tableName ); }//end applyAccessControlToQuery() @@ -526,7 +530,8 @@ public function buildFilteredQuery(array $query, Schema $schema, string $tableNa _rbac: $_rbac, _multitenancy: $_multitenancy, multitenancyExplicit: $multitenancyExplicit, - registerId: ($registerId ?? $this->registerIdFromQuery(query: $query)) + registerId: ($registerId ?? $this->registerIdFromQuery(query: $query)), + tableName: $tableName ); // Apply metadata, object-field and ID filters. @@ -797,7 +802,8 @@ public function buildWhereConditionsSql( schema: $schema, rbac: $_rbac, connection: $connection, - registerId: $registerId + registerId: $registerId, + tableName: $tableName ) ); @@ -1047,6 +1053,7 @@ private function draftConditionSql(array $query, string $columnPrefix, ?string $ * @param mixed $rbac The raw `_rbac` flag as the caller wrote it. * @param mixed $connection The connection, for value quoting. * @param integer|null $registerId The register whose table this is built for. + * @param string|null $tableName The bare magic table, so a missing `_status` column is left out of the draft predicate. * * @return string[] The conditions, without leading AND. * @@ -1057,7 +1064,8 @@ private function boundaryConditionsSql( Schema $schema, mixed $rbac, mixed $connection, - ?int $registerId + ?int $registerId, + ?string $tableName = null ): array { $conditions = []; @@ -1092,7 +1100,7 @@ private function boundaryConditionsSql( } if ($rbac === true) { - $rbacCondition = $this->buildRbacConditionSql(schema: $schema); + $rbacCondition = $this->buildRbacConditionSql(schema: $schema, tableName: $tableName); if ($rbacCondition !== null) { $conditions[] = $rbacCondition; } @@ -1361,12 +1369,13 @@ private function buildOrganizationConditionSql( /** * Build the RBAC SQL condition * - * @param Schema $schema Schema for RBAC rules + * @param Schema $schema Schema for RBAC rules + * @param string|null $tableName The bare magic table, so a missing `_status` column is left out of the draft predicate * * @return string|null SQL condition or null if no RBAC filtering needed */ - private function buildRbacConditionSql(Schema $schema): ?string { - $rbacResult = $this->rbacHandler->buildRbacConditionsSql(schema: $schema, action: 'read'); + private function buildRbacConditionSql(Schema $schema, ?string $tableName = null): ?string { + $rbacResult = $this->rbacHandler->buildRbacConditionsSql(schema: $schema, action: 'read', tableName: $tableName); if ($rbacResult['bypass'] === false) { // User doesn't have unconditional access. @@ -2477,6 +2486,7 @@ private function registerIdFromQuery(array $query): ?int { * @param bool $_multitenancy Whether multitenancy filtering is enabled * @param bool $multitenancyExplicit Whether multitenancy was explicitly requested * @param int|null $registerId The register of the table being read, for shared master data + * @param string|null $tableName The bare magic table being read, so a missing `_status` column is left out of the draft predicate * * @return void * @@ -2489,6 +2499,7 @@ private function applyAccessControlFilters( bool $_multitenancy, bool $multitenancyExplicit, ?int $registerId = null, + ?string $tableName = null, ): void { // Whether the organisation boundary applies is decided in ONE place // (multitenancyApplies), because the string-SQL path renders the same @@ -2530,7 +2541,8 @@ private function applyAccessControlFilters( $this->rbacHandler->applyRbacFilters( qb: $qb, schema: $schema, - action: 'read' + action: 'read', + tableName: $tableName ); } }//end applyAccessControlFilters() diff --git a/lib/Service/Aggregation/AggregationRunner.php b/lib/Service/Aggregation/AggregationRunner.php index 21b181592b..f814328c5a 100644 --- a/lib/Service/Aggregation/AggregationRunner.php +++ b/lib/Service/Aggregation/AggregationRunner.php @@ -2640,7 +2640,7 @@ private function tryNativeAggregation( // restriction (an admin); internal callers that already decided who // may see the figure pass `rowRbac: false`. if ($rowRbac === true) { - $rowPredicate = $this->magicMapper->rbacRowPredicateSql(schema: $schema, action: 'read'); + $rowPredicate = $this->magicMapper->rbacRowPredicateSql(schema: $schema, action: 'read', register: $register); if ($rowPredicate !== null) { $whereParts[] = $rowPredicate; } diff --git a/lib/Service/Query/RelatedRowQueryApplier.php b/lib/Service/Query/RelatedRowQueryApplier.php index 3b6325ffa7..73ff4a6bfc 100644 --- a/lib/Service/Query/RelatedRowQueryApplier.php +++ b/lib/Service/Query/RelatedRowQueryApplier.php @@ -99,10 +99,11 @@ public function apply(IQueryBuilder $qb, array $query, Register $register, strin foreach (array_values($filters) as $position => $filter) { $alias = sprintf('rel%d', $position); $schema = $this->resolveSchema(name: $filter->schema); + $relatedTable = $this->tableHandler->getTableNameForRegisterSchema(register: $register, schema: $schema); $rendered = $clause->render( filter: $filter, engine: $engine, - table: $this->tableHandler->getTableNameForRegisterSchema(register: $register, schema: $schema), + table: $relatedTable, outerAlias: $outerAlias, innerAlias: $alias, // 🔴 THE ACCESS PREDICATE FOR THE RELATED SCHEMA, NOT THE OUTER @@ -112,7 +113,8 @@ public function apply(IQueryBuilder $qb, array $query, Register $register, strin accessPredicate: $this->rbacHandler->buildRbacPredicateForAlias( schema: $schema, alias: $alias, - action: 'read' + action: 'read', + tableName: $relatedTable ), parameterPrefix: $alias, storage: RelatedRowExistsClause::STORAGE_COLUMNS diff --git a/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php b/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php index 8dc85d0b98..8663fdbea1 100644 --- a/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php +++ b/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php @@ -13,6 +13,7 @@ namespace Unit\Db\MagicMapper; +use OCA\OpenRegister\Db\MagicMapper\MagicMetadataColumnHealer; use OCA\OpenRegister\Db\MagicMapper\MagicRbacHandler; use OCA\OpenRegister\Db\MagicMapper\RbacResolvers; use OCA\OpenRegister\Db\Schema; @@ -47,7 +48,7 @@ class MagicRbacDraftVisibilityTest extends TestCase { * * @param array $groups The caller's groups. */ - private function handlerFor(string $userId, array $groups = []): MagicRbacHandler { + private function handlerFor(string $userId, array $groups = [], ?MagicMetadataColumnHealer $healer = null): MagicRbacHandler { $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($userId); $userSession = $this->createMock(IUserSession::class); @@ -63,7 +64,9 @@ private function handlerFor(string $userId, array $groups = []): MagicRbacHandle $this->createMock(ConditionMatcher::class), $this->createMock(ContainerInterface::class), new NullLogger(), - new RbacResolvers(objectScopeResolver: null, objectGrantResolver: null, denyResolver: new DenyResolver(new DenyEntryMatcher())) + new RbacResolvers(objectScopeResolver: null, objectGrantResolver: null, denyResolver: new DenyResolver(new DenyEntryMatcher())), + null, + $healer ); }//end handlerFor() @@ -103,6 +106,34 @@ public function testAnOpenSchemaStillHidesOthersDrafts(): void { $this->assertStringContainsString("_status <> 'draft'", $predicate); }//end testAnOpenSchemaStillHidesOthersDrafts() + /** + * A table that could not be given its `_status` column cannot hold a draft: the predicate leaves it out, and keeps it for a healthy table. + * + * @spec openspec/changes/magic-table-metadata-self-healing/specs/object-lifecycle/spec.md#requirement-a-table-that-cannot-be-healed-degrades-quietly-req-mth-002 + */ + public function testADegradedTableGetsNoDraftPredicate(): void { + $healer = $this->createMock(MagicMetadataColumnHealer::class); + $healer->method('missingFor')->willReturnCallback( + static fn (string $table): array => ($table === 'openregister_table_1_2' ? ['_status'] : []) + ); + $handler = $this->handlerFor('alice', ['behandelaars'], $healer); + $schema = $this->schema(['read' => ['behandelaars']]); + + $degraded = (string)$handler->buildRbacRowPredicateSql(schema: $schema, tableName: 'openregister_table_1_2'); + $this->assertStringNotContainsString('_status', $degraded); + $this->assertStringContainsString("_owner = 'alice'", $degraded); + + $healthy = (string)$handler->buildRbacRowPredicateSql(schema: $schema, tableName: 'openregister_table_1_3'); + $this->assertStringContainsString("(_status IS NULL OR _status <> 'draft')", $healthy); + + // A caller that names no table keeps the predicate: narrower, so safe. + $unnamed = (string)$handler->buildRbacRowPredicateSql(schema: $schema); + $this->assertStringContainsString('_status', $unnamed); + + $aliased = $handler->buildRbacPredicateForAlias(schema: $schema, alias: 'rel0', tableName: 'openregister_table_1_2'); + $this->assertStringNotContainsString('_status', $aliased); + }//end testADegradedTableGetsNoDraftPredicate() + /** * A schema with draftsVisible shows drafts to everyone who may read; an admin is never restricted. */ From d4aa263863ae244aef7446a55b1eea3c5eda34b1 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:06:58 +0200 Subject: [PATCH 14/37] =?UTF-8?q?fix(review):=20#4580=20data-f5=20?= =?UTF-8?q?=E2=80=94=20Aggregation=20fast=20path=20counts=20drafts=20the?= =?UTF-8?q?=20list=20hides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native aggregation path adds DraftListLens's clause for the mode a query-less list uses (EXCLUDE, or NONE for a schema with draftsListed: false) next to the archive predicate: others' drafts are out of the count, the signed-in caller's own stay in through a bound owner placeholder (kept in step with the organisation bindings that follow), and the clause is left out of a table known to lack _status. This also holds for an administrator, whose RBAC predicate is null. New tests assert the generated WHERE and bindings for nobody, a user, draftsListed false and a degraded table. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660359 Check: phpunit tests/Unit/Service/Aggregation: 276 tests, 21 errors ('PDOException: could not find driver', identical at HEAD) and no other failure; phpcs on the lib file clean. The 'count over a table with one draft and one active row returns 1' Done-when needs a database and was not run (SQL-level assertions stand in for it). Co-Authored-By: Claude Opus 5.5 --- lib/Service/Aggregation/AggregationRunner.php | 29 +++++++ .../AggregationRunnerArchiveDefaultTest.php | 75 ++++++++++++++++++- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/lib/Service/Aggregation/AggregationRunner.php b/lib/Service/Aggregation/AggregationRunner.php index f814328c5a..6942765819 100644 --- a/lib/Service/Aggregation/AggregationRunner.php +++ b/lib/Service/Aggregation/AggregationRunner.php @@ -50,6 +50,7 @@ use OCA\OpenRegister\Exception\RegisterNotFoundException; use OCA\OpenRegister\Exception\SchemaNotInRegisterException; use OCA\OpenRegister\Service\LanguageService; +use OCA\OpenRegister\Service\Object\DraftListLens; use OCA\OpenRegister\Service\Object\PermissionHandler; use OCA\OpenRegister\Service\Object\TranslationHandler; use OCA\OpenRegister\Service\ObjectSource\DbalObjectSourceProvider; @@ -2556,6 +2557,34 @@ private function tryNativeAggregation( $whereParts[] = "(_archived IS NULL OR _archived = 'null' OR _archived = '')"; } + // Draft lens. A list hides drafts by default (and, for a schema with + // `draftsListed: false`, the caller's own as well); a count over the + // same rows has to agree with it, or a tile reads higher than the page + // under it. The clause is DraftListLens's own, for the mode a list with + // no query would use, and is left out of a table that could not be + // given its `_status` column (REQ-MTH-002). + $draftLens = new DraftListLens(); + $statusMissing = in_array('_status', $this->magicMapper->getMetadataColumnHealer()->missingFor(tableName: $tableName), true); + if ($statusMissing === false) { + $draftOwner = $this->userSession->getUser()?->getUID(); + $ownerBinding = null; + if ($draftOwner !== null && $draftOwner !== '') { + $ownerBinding = '?'; + } + + $draftPredicate = $draftLens->sql( + mode: $draftLens->modeFor(query: [], schema: $schema), + columnPrefix: '', + owner: $ownerBinding + ); + if ($draftPredicate !== null) { + $whereParts[] = $draftPredicate; + if ($ownerBinding !== null && str_contains($draftPredicate, '?') === true) { + $bindings[] = $draftOwner; + } + } + } + // SECURITY: the organisation boundary. This fast path bypasses // MagicMapper entirely, so it must reproduce the SAME rule the list // path applies — no wider, and no narrower. diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerArchiveDefaultTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerArchiveDefaultTest.php index 797763a142..e9e5f4c490 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerArchiveDefaultTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerArchiveDefaultTest.php @@ -173,6 +173,68 @@ public function testTheSoftDeletePredicateIsStillThere(): void { $this->assertStringContainsString('_deleted IS NULL', (string)$captured['sql']); }//end testTheSoftDeletePredicateIsStillThere() + /** + * A count leaves drafts out like the list does: the clause is there for nobody, and with the caller's own drafts admitted for a signed-in user. + * + * @return void + */ + public function testTheCountAgreesWithTheListAboutDrafts(): void { + $this->wirePlatform(platform: $this->createMock(MySQLPlatform::class)); + $captured = $this->captureSql(); + + $this->makeRunner()->runAdhoc(register: $this->makeRegister(), schema: $this->makeSchema(), query: $this->dayBucketQuery()); + $this->assertStringContainsString("(_status IS NULL OR _status <> 'draft')", (string)$captured['sql']); + $this->assertStringNotContainsString('_owner = ?', (string)$captured['sql']); + // The first binding is the organisation's: no owner term for nobody. + $this->assertSame('__no_active_org__', $captured['bindings'][0]); + }//end testTheCountAgreesWithTheListAboutDrafts() + + /** + * A signed-in caller's own drafts stay in the count (the list shows them), through a binding; draftsListed: false drops them as the list does. + * + * @return void + */ + public function testTheCallersOwnDraftsFollowTheSchemasListSetting(): void { + $this->wirePlatform(platform: $this->createMock(MySQLPlatform::class)); + $captured = $this->captureSql(); + $user = $this->createMock(\OCP\IUser::class); + $user->method('getUID')->willReturn('alice'); + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + + $this->makeRunner(session: $session)->runAdhoc(register: $this->makeRegister(), schema: $this->makeSchema(), query: $this->dayBucketQuery()); + $this->assertStringContainsString("(_status IS NULL OR _status <> 'draft') OR _owner = ?", (string)$captured['sql']); + // The placeholders and the bindings stay in step: the draft clause comes before the organisation one. + $this->assertSame(['alice', '__no_active_org__'], array_slice($captured['bindings'], 0, 2)); + + $captured['sql'] = null; + $captured['bindings'] = []; + $schema = $this->makeSchema(); + $schema->setConfiguration(['draftsListed' => false]); + $this->makeRunner(session: $session)->runAdhoc(register: $this->makeRegister(), schema: $schema, query: $this->dayBucketQuery()); + $this->assertStringContainsString("_status <> 'draft'", (string)$captured['sql']); + $this->assertStringNotContainsString('_owner = ?', (string)$captured['sql']); + $this->assertNotContains('alice', $captured['bindings']); + }//end testTheCallersOwnDraftsFollowTheSchemasListSetting() + + /** + * A table that could not be given its `_status` column cannot hold a draft: the clause is left out. + * + * @return void + */ + public function testADegradedTableGetsNoDraftClause(): void { + $this->wirePlatform(platform: $this->createMock(MySQLPlatform::class)); + $captured = $this->captureSql(); + $healer = $this->createMock(\OCA\OpenRegister\Db\MagicMapper\MagicMetadataColumnHealer::class); + $healer->method('missingFor')->willReturn(['_status']); + $this->magicMapper->method('getMetadataColumnHealer')->willReturn($healer); + + $this->makeRunner()->runAdhoc(register: $this->makeRegister(), schema: $this->makeSchema(), query: $this->dayBucketQuery()); + + $this->assertNotNull($captured['sql']); + $this->assertStringNotContainsString('_status', (string)$captured['sql']); + }//end testADegradedTableGetsNoDraftClause() + // ----------------------------------------------------------------------- // Helpers. // ----------------------------------------------------------------------- @@ -203,10 +265,15 @@ private function dayBucketQuery(): AggregationQuery { * @return \ArrayObject Mutable container with an `sql` key. */ private function captureSql(): \ArrayObject { - $captured = new \ArrayObject(['sql' => null]); + $captured = new \ArrayObject(['sql' => null, 'bindings' => []]); $stmt = $this->createMock(IPreparedStatement::class); $result = $this->createMock(IResult::class); - $stmt->method('execute')->willReturn($result); + $stmt->method('execute')->willReturnCallback( + function (?array $params = null) use ($result, $captured) { + $captured['bindings'] = ($params ?? []); + return $result; + } + ); $stmt->method('fetch')->willReturn(false); $this->db->method('prepare')->willReturnCallback( @@ -261,7 +328,7 @@ private function makeRegister(): Register { * * @return AggregationRunner The runner. */ - private function makeRunner(): AggregationRunner { + private function makeRunner(?IUserSession $session = null): AggregationRunner { return new AggregationRunner( magicMapper: $this->magicMapper, registerMapper: $this->registerMapper, @@ -270,7 +337,7 @@ private function makeRunner(): AggregationRunner { db: $this->db, cache: $this->cache, permissionHandler: $this->permissionHandler, - userSession: $this->userSession, + userSession: ($session ?? $this->userSession), organisationService: $this->organisationService, organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), From a33e49b49f16a07d2521d64bc8d6004a5b89ffcd Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:07:39 +0200 Subject: [PATCH 15/37] =?UTF-8?q?fix(review):=20#4580=20plat-f5=20?= =?UTF-8?q?=E2=80=94=20A=20later=20listener's=20allow()=20overrides=20an?= =?UTF-8?q?=20earlier=20refusal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DecisionChangeRequestedEvent keeps a refused flag: once refuse() was called, allow() does nothing, so the registration order of two apps' listeners cannot turn a veto into an allow. testAnAllowClearsTheReason is replaced by a sticky-refusal test covering both orders; the supervisor-may-be-refused and listener-may-allow cases are untouched and green. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660718 Sweep: DecisionVisibility's event is deny-by-default and allow-only (order independent); no other vetoable event in lib/Event has an allow() that clears a refusal. Check: phpunit tests/Unit/Service/Decision + tests/Unit/Event OK 245 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Event/DecisionChangeRequestedEvent.php | 17 +++++++++++++++- .../Decision/DecisionOwnershipTest.php | 20 +++++++++++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/lib/Event/DecisionChangeRequestedEvent.php b/lib/Event/DecisionChangeRequestedEvent.php index a2b2aa7210..60f8239956 100644 --- a/lib/Event/DecisionChangeRequestedEvent.php +++ b/lib/Event/DecisionChangeRequestedEvent.php @@ -51,6 +51,13 @@ class DecisionChangeRequestedEvent extends Event { */ private ?string $reason = null; + /** + * Whether a listener has refused: a refusal is sticky. + * + * @var bool + */ + private bool $refused = false; + /** * Constructor. * @@ -120,13 +127,20 @@ public function getSubject(): array { }//end getSubject() /** - * Let the change through. + * Let the change through, unless a listener has already refused it. + * + * A refusal is a veto and stays: with two apps listening, the order they + * are registered in must not turn one app's refusal into the other's allow. * * @return void * * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 */ public function allow(): void { + if ($this->refused === true) { + return; + } + $this->allowed = true; $this->reason = null; }//end allow() @@ -141,6 +155,7 @@ public function allow(): void { * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002 */ public function refuse(string $reason): void { + $this->refused = true; $this->allowed = false; $this->reason = $reason; }//end refuse() diff --git a/tests/Unit/Service/Decision/DecisionOwnershipTest.php b/tests/Unit/Service/Decision/DecisionOwnershipTest.php index 193cd6788c..908186f577 100644 --- a/tests/Unit/Service/Decision/DecisionOwnershipTest.php +++ b/tests/Unit/Service/Decision/DecisionOwnershipTest.php @@ -200,19 +200,27 @@ public function testAFailingListenerRefuses(): void { }//end testAFailingListenerRefuses() /** - * An allow after a refusal clears the reason. + * A refusal is sticky: a later allow() cannot undo it, so listener order decides nothing. * * @return void */ - public function testAnAllowClearsTheReason(): void { + public function testARefusalIsStickyWhateverTheListenerOrder(): void { $event = new DecisionChangeRequestedEvent(decision: $this->decision(), actorId: 'x', policy: $this->decider(), subject: [], allowed: false); - $event->refuse(reason: 'first'); + $event->refuse(reason: 'case closed'); $event->allow(); - self::assertTrue($event->isAllowed()); - self::assertNull($event->getReason()); + self::assertFalse($event->isAllowed()); + self::assertSame('case closed', $event->getReason()); + + // Allow first, then refuse: the same answer. + $other = new DecisionChangeRequestedEvent(decision: $this->decision(), actorId: 'x', policy: $this->decider(), subject: [], allowed: false); + $other->allow(); + self::assertTrue($other->isAllowed()); + $other->refuse(reason: 'case closed'); + self::assertFalse($other->isAllowed()); + self::assertSame('x', $event->getActorId()); self::assertSame('d-1', $event->getDecision()->getUuid()); self::assertTrue($event->getPolicy()->isDeciderOwned()); - }//end testAnAllowClearsTheReason() + }//end testARefusalIsStickyWhateverTheListenerOrder() }//end class From e19745bc962569b05be64a34162a393d3c8d156b Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:08:45 +0200 Subject: [PATCH 16/37] =?UTF-8?q?fix(review):=20#4580=20aud-p1=20=E2=80=94?= =?UTF-8?q?=20The=20"may=20not=20let=20itself=20in"=20check=20ignores=20re?= =?UTF-8?q?mote=20principals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GrantManageGuard::letsItselfIn() takes the remote principal being named and refuses it outright for a holder admitted by the special right alone (a federated cloud id or remote group cannot be proved not to be the caller); ObjectSharingService passes remote and remote_group grantees. The selfGrants data provider of GrantManageSpecialRightTest gains a remote and a remote_group case. The owner and the administrator are unaffected. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660923 Sweep: letsItselfIn has two callers; ObjectOwnershipService names only a group (remote is not applicable there). Check: phpunit tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php OK 17 tests; phpcs on the two lib files clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Rbac/GrantManageGuard.php | 7 ++++++- lib/Service/Rbac/ObjectSharingService.php | 3 ++- tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php | 2 ++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/Service/Rbac/GrantManageGuard.php b/lib/Service/Rbac/GrantManageGuard.php index cdaf9403b2..7d3bef9429 100644 --- a/lib/Service/Rbac/GrantManageGuard.php +++ b/lib/Service/Rbac/GrantManageGuard.php @@ -115,16 +115,21 @@ public function admittedAs(?string $userId, array $userGroups, ObjectEntity $obj * @param string[] $userGroups The caller's group ids. * @param string|null $user The user being let in, or null. * @param string|null $group The group being let in, or null. + * @param string|null $remote The remote user or remote group being let in, or null. * * @return bool True when the request must be refused. * * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md */ - public function letsItselfIn(?string $admittedAs, ?string $userId, array $userGroups, ?string $user, ?string $group): bool { + public function letsItselfIn(?string $admittedAs, ?string $userId, array $userGroups, ?string $user, ?string $group, ?string $remote = null): bool { if ($admittedAs !== self::AS_HOLDER) { return false; } + if ($remote !== null) { + return true; + } + if ($user !== null && $user === $userId) { return true; } diff --git a/lib/Service/Rbac/ObjectSharingService.php b/lib/Service/Rbac/ObjectSharingService.php index 7e3faa59e0..62c44df064 100644 --- a/lib/Service/Rbac/ObjectSharingService.php +++ b/lib/Service/Rbac/ObjectSharingService.php @@ -706,7 +706,8 @@ private function requireMayManageGrants( userId: $uid, userGroups: $groups, user: ($principal['user'] ?? null), - group: ($principal['group'] ?? null) + group: ($principal['group'] ?? null), + remote: ($principal['remote'] ?? $principal['remote_group'] ?? null) ) === true ) { throw new NotAuthorizedException( diff --git a/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php b/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php index 1a4f67f398..6f516d6aca 100644 --- a/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php +++ b/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php @@ -314,6 +314,8 @@ public static function selfGrants(): array { 'its own account, padded' => ['user', ' service-account '], 'the group that holds the right' => ['group', self::SERVICE_GROUP], 'another group it is in' => ['group', 'jobs'], + 'a federated cloud id' => ['remote', 'service-account@other.example'], + 'a remote group' => ['remote_group', 'staff@other.example'], ]; }//end selfGrants() From d0d2725b327995d489660120abe06284d0ad88dc Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:09:52 +0200 Subject: [PATCH 17/37] =?UTF-8?q?fix(review):=20#4580=20aud-p4=20=E2=80=94?= =?UTF-8?q?=20Guard=20failure=20imports=20shipped=20authorization=20unguar?= =?UTF-8?q?ded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the guard throws, the import still finishes but now writes the incoming definition with the instance's own authorization blocks put back (the schema's, and each property the instance already holds; a block the instance lacks is not added, a property the instance does not hold brings its shipped block), and logs it. Authorization blocks the merge took from the app because only the app changed them are now recorded on the audit trail (configuration.conflict.decided with decision applied-shipped, once per block root, skipping roots already recorded for an explicit decision); recordDecision() gets a decision argument for it. New tests: a throwing baseline read keeps the instance authorization, and an app-only authorization change is audited. Thread: verdict https://github.com/ConductionNL/openregister/pull/4580#pullrequestreview-5535345548 (this finding has no inline thread) Check: phpunit tests/Unit/Service/ShippedBaseline + RegisterDescriptorServiceTest OK; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- .../ShippedConfigurationGuard.php | 91 +++++++++++++++++-- .../ShippedConfigurationGuardTest.php | 61 ++++++++++++- 2 files changed, 144 insertions(+), 8 deletions(-) diff --git a/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php b/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php index d3e2f82238..71256c8156 100644 --- a/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php +++ b/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php @@ -163,6 +163,7 @@ public function guardSchemaUpdate( decisions: $decisions ); $this->recordReplaced(slug: $slug, app: $app, roots: $result['replacedLocal']); + $this->recordAppliedAuthorization(slug: $slug, app: $app, applied: $result['applied'], recorded: array_merge($result['replacedLocal'], $decisions)); if ($record === true) { $this->baselines->record( @@ -199,15 +200,17 @@ public function guardSchemaUpdate( 'conflicts' => $result['conflicts'], ]; } catch (Throwable $e) { - // An upgrade must finish. A guard that cannot run means the import - // behaves as it did before the guard existed, which is a known - // state, not a broken one. + // An upgrade must finish, so the import still goes ahead. But a + // guard that cannot run must not hand the app's authorization over + // what an administrator set here: that is the one thing the guard + // exists to prevent, and failing towards the shipped policy is + // failing open. The instance's own authorization blocks are kept. $this->logger->error( - sprintf('[ShippedConfigurationGuard] %s: guard skipped, importing unguarded: %s', $slug, $e->getMessage()) + sprintf('[ShippedConfigurationGuard] %s: guard skipped, importing unguarded but keeping the instance authorization: %s', $slug, $e->getMessage()) ); return [ - 'definition' => $incoming, + 'definition' => $this->keepingLiveAuthorization(live: $live, incoming: $incoming), 'baseline' => null, 'guarded' => false, 'applied' => [], @@ -217,6 +220,79 @@ public function guardSchemaUpdate( }//end try }//end guardSchemaUpdate() + /** + * The incoming definition with the instance's own authorization blocks put back. + * + * For an import the guard could not judge: the schema's `authorization` and + * each existing property's `authorization` stay as the instance has them + * (a block the instance does not have is not added); a property the + * instance does not hold yet brings its shipped block. + * + * @param array $live What the instance runs. + * @param array $incoming What the app now ships. + * + * @return array The definition to write. + */ + private function keepingLiveAuthorization(array $live, array $incoming): array { + $definition = $incoming; + if (array_key_exists('authorization', $live) === true) { + $definition['authorization'] = $live['authorization']; + } else { + unset($definition['authorization']); + } + + $liveProperties = ($live['properties'] ?? null); + if (is_array($liveProperties) === false || is_array($definition['properties'] ?? null) === false) { + return $definition; + } + + foreach ($definition['properties'] as $name => $property) { + if (is_array($property) === false || is_array($liveProperties[$name] ?? null) === false) { + continue; + } + + if (array_key_exists('authorization', $liveProperties[$name]) === true) { + $definition['properties'][$name]['authorization'] = $liveProperties[$name]['authorization']; + } else { + unset($definition['properties'][$name]['authorization']); + } + } + + return $definition; + }//end keepingLiveAuthorization() + + /** + * Record, on the audit trail, the authorization blocks an upgrade took from the app because only the app changed them. + * + * An explicit decision is recorded by recordReplaced(); this is the other + * way a block changes under an instance, and who widened access is what + * the trail has to be able to answer. + * + * @param string $slug The schema slug. + * @param string $app The app that ships it. + * @param array $applied The paths the merge took from upstream. + * @param array $recorded Roots already on the trail. + * + * @return void + */ + private function recordAppliedAuthorization(string $slug, string $app, array $applied, array $recorded): void { + $roots = []; + foreach ($applied as $path) { + $path = (string)$path; + $sep = preg_quote(DescriptorParts::SEPARATOR, '/'); + $pattern = '/^(authorization|properties' . $sep . '[^' . $sep . ']+' . $sep . 'authorization)(?:' . $sep . '|$)/'; + if (preg_match($pattern, $path, $matches) === 1) { + $roots[$matches[1]] = true; + } + } + + foreach (array_keys($roots) as $root) { + if (in_array($root, $recorded, true) === false) { + $this->recordDecision(slug: $slug, path: $root, app: $app, decision: 'applied-shipped'); + } + } + }//end recordAppliedAuthorization() + /** * Record what an app shipped for a schema it has just created. * @@ -522,15 +598,16 @@ private function recordReplaced(string $slug, string $app, array $roots): void { * @param string $path The part. * @param string $app The app. * @param string $via What decided it, when not the acting user alone (an occ re-import). + * @param string $decision `accept-shipped` for a conflict an administrator took, `applied-shipped` for a block only the app changed. * * @return void */ - private function recordDecision(string $slug, string $path, string $app, string $via = ''): void { + private function recordDecision(string $slug, string $path, string $app, string $via = '', string $decision = 'accept-shipped'): void { $changed = [ 'schema' => $slug, 'path' => $path, 'app' => $app, - 'decision' => 'accept-shipped', + 'decision' => $decision, ]; if ($via !== '') { $changed['via'] = $via; diff --git a/tests/Unit/Service/ShippedBaseline/ShippedConfigurationGuardTest.php b/tests/Unit/Service/ShippedBaseline/ShippedConfigurationGuardTest.php index 05a311d9ab..5b7309f18b 100644 --- a/tests/Unit/Service/ShippedBaseline/ShippedConfigurationGuardTest.php +++ b/tests/Unit/Service/ShippedBaseline/ShippedConfigurationGuardTest.php @@ -60,14 +60,19 @@ class ShippedConfigurationGuardTest extends TestCase { * @param array|null $baseline The stored baseline definition, or null for none. * @param IUserSession|null $session The session. * @param ShippedAuthorizationDecision|null $decision The administrator's decision, when one is in effect. + * @param bool $readFails Whether reading the baseline throws, so the guard cannot run. * * @return ShippedConfigurationGuard The guard. */ - private function guard(?array $baseline, ?IUserSession $session = null, ?ShippedAuthorizationDecision $decision = null): ShippedConfigurationGuard { + private function guard(?array $baseline, ?IUserSession $session = null, ?ShippedAuthorizationDecision $decision = null, bool $readFails = false): ShippedConfigurationGuard { $store = $this->createMock(ShippedBaselineStore::class); $store->method('schemaSubject')->willReturnCallback( static fn (string $slug): string => ('schema:' . $slug) ); + if ($readFails === true) { + $store->method('read')->willThrowException(new \RuntimeException('baseline store down')); + } + $store->method('read')->willReturn( ($baseline === null ? null : [ 'definition' => $baseline, @@ -578,4 +583,58 @@ public function testAnAdministratorsReimportTakesTheShippedAuthorization(): void $this->assertSame([['schema' => 'portalAccount', 'path' => 'authorization']], $decision->replaced()); $this->assertNull($decision->via(), 'the decision ends with the operation'); }//end testAnAdministratorsReimportTakesTheShippedAuthorization() + + /** + * A guard that cannot run still lets the upgrade finish, but never hands the instance's authorization to the shipped one. + * + * @return void + */ + public function testAGuardThatCannotRunKeepsTheInstanceAuthorization(): void { + $live = [ + 'title' => 'T', + 'authorization' => ['read' => ['staff']], + 'properties' => [ + 'bsn' => ['type' => 'string', 'authorization' => ['read' => ['staff']]], + 'name' => ['type' => 'string'], + ], + ]; + $incoming = [ + 'title' => 'T2', + 'authorization' => ['read' => ['authenticated']], + 'properties' => [ + 'bsn' => ['type' => 'string', 'authorization' => ['read' => ['authenticated']]], + 'name' => ['type' => 'string', 'authorization' => ['read' => ['authenticated']]], + 'added' => ['type' => 'string', 'authorization' => ['read' => ['shipped']]], + ], + ]; + + $result = $this->guard(baseline: null, readFails: true)->guardSchemaUpdate(slug: 'person', live: $live, incoming: $incoming, app: 'dossiq', appVersion: '1.3.0'); + + $this->assertFalse($result['guarded']); + $this->assertNull($result['baseline']); + $this->assertSame('T2', $result['definition']['title'], 'the rest of the upgrade still lands'); + $this->assertSame(['read' => ['staff']], $result['definition']['authorization']); + $this->assertSame(['read' => ['staff']], $result['definition']['properties']['bsn']['authorization']); + $this->assertArrayNotHasKey('authorization', $result['definition']['properties']['name']); + $this->assertSame(['read' => ['shipped']], $result['definition']['properties']['added']['authorization'], 'a property the instance lacks brings its shipped block'); + }//end testAGuardThatCannotRunKeepsTheInstanceAuthorization() + + /** + * An authorization block taken because only the app changed it is on the audit trail. + * + * @return void + */ + public function testAnAuthorizationBlockTakenFromTheAppIsAudited(): void { + $block = ['read' => ['authenticated']]; + $result = $this->guard(baseline: ['authorization' => $block])->guardSchemaUpdate( + slug: 'portalAccount', + live: ['authorization' => $block], + incoming: ['authorization' => ['read' => ['portaliq-medewerkers']]], + app: 'portaliq', + appVersion: '0.98.0' + ); + + $this->assertSame(['read' => ['portaliq-medewerkers']], $result['definition']['authorization']); + $this->assertSame([ShippedConfigurationGuard::ACTION_DECIDED], $this->actions); + }//end testAnAuthorizationBlockTakenFromTheAppIsAudited() }//end class From 5faeef9582317db56d386a73154e9731d89e8734 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:14:02 +0200 Subject: [PATCH 18/37] =?UTF-8?q?fix(review):=20#4580=20surf-f4=20?= =?UTF-8?q?=E2=80=94=20Copies=20still=20send=20users=20to=20the=20removed?= =?UTF-8?q?=20personal=20settings=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relink notification now links to the OpenRegister app page with #user-settings (as the OAuth callback does) and says 'user settings'; the admin hint in PushNotificationsConfiguration.vue, its en/nl l10n keys (nl: gebruikersinstellingen), the credential-oauth2-connect requirement heading and text, the notificatie-engine requirement sentence, and the stale header comments in BrowserNotificationsSection.vue and OAuth2Endpoints.php are reworded to the user settings dialog. Other languages' l10n entries keep the old key until Transifex syncs. New NotifierTest case asserts the link. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660858 Sweep: git grep -i 'personal settings' over lib, src and openspec/specs leaves only comments that say OpenRegister is NOT in Nextcloud's personal settings (CredentialOauth2Controller, App.vue, UserSettings.vue). Check: phpunit tests/Unit/Notification/NotifierTest.php OK 22 tests; l10n/en.json and nl.json parse. The anchored code is spec text and strings only, no eslint run was needed beyond the one-line template change. Co-Authored-By: Claude Opus 5.5 --- l10n/en.js | 2 +- l10n/en.json | 2 +- l10n/nl.js | 2 +- l10n/nl.json | 2 +- lib/Notification/Notifier.php | 8 ++--- lib/Service/Credential/OAuth2Endpoints.php | 2 +- .../specs/credential-oauth2-connect/spec.md | 4 +-- openspec/specs/notificatie-engine/spec.md | 2 +- .../BrowserNotificationsSection.vue | 14 ++++----- .../PushNotificationsConfiguration.vue | 2 +- tests/Unit/Notification/NotifierTest.php | 31 +++++++++++++++++++ 11 files changed, 49 insertions(+), 22 deletions(-) diff --git a/l10n/en.js b/l10n/en.js index f827348952..a9d551312e 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1609,7 +1609,7 @@ OC.L10N.register( "Real-time push notifications are active. Connected clients receive instant updates when objects are created, updated, or deleted.": "Real-time push notifications are active. Connected clients receive instant updates when objects are created, updated, or deleted.", "Browser Web Push (VAPID)": "Browser Web Push (VAPID)", "Browser web push is configured": "Browser web push is configured", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.", "VAPID public key": "VAPID public key", "Browser web push is not configured": "Browser web push is not configured", "No VAPID keypair is configured yet. Generate one with the following occ command to enable browser notifications:": "No VAPID keypair is configured yet. Generate one with the following occ command to enable browser notifications:", diff --git a/l10n/en.json b/l10n/en.json index ad83c873c7..49e0d5938b 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1605,7 +1605,7 @@ "Real-time push notifications are active. Connected clients receive instant updates when objects are created, updated, or deleted.": "Real-time push notifications are active. Connected clients receive instant updates when objects are created, updated, or deleted.", "Browser Web Push (VAPID)": "Browser Web Push (VAPID)", "Browser web push is configured": "Browser web push is configured", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.", "VAPID public key": "VAPID public key", "Browser web push is not configured": "Browser web push is not configured", "No VAPID keypair is configured yet. Generate one with the following occ command to enable browser notifications:": "No VAPID keypair is configured yet. Generate one with the following occ command to enable browser notifications:", diff --git a/l10n/nl.js b/l10n/nl.js index 54ebbc735e..1382c6c28a 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -64,7 +64,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} mislukt", "0 = unlimited": "0 = onbeperkt", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Een flow voert een reeks stappen uit wanneer er iets gebeurt — een object wijzigt, een planning wordt geactiveerd, of je voert hem zelf uit. Deze lijst toont de flows van elke app.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Er is een VAPID-sleutelpaar geconfigureerd. Gebruikers kunnen zich via hun persoonlijke instellingen aanmelden voor browsermeldingen, die ook worden bezorgd wanneer het browsertabblad gesloten is.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Er is een VAPID-sleutelpaar geconfigureerd. Gebruikers kunnen zich via hun gebruikersinstellingen aanmelden voor browsermeldingen, die ook worden bezorgd wanneer het browsertabblad gesloten is.", "A deactivation request is pending.": "Er is een deactiveringsverzoek in behandeling.", "ADDRESS": "ADRES", "AI Assistant": "AI-assistent", diff --git a/l10n/nl.json b/l10n/nl.json index f4e28ff085..15211fdcbd 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -60,7 +60,7 @@ ", {failed} failed": ", {failed} mislukt", "0 = unlimited": "0 = onbeperkt", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Een flow voert een reeks stappen uit wanneer er iets gebeurt — een object wijzigt, een planning wordt geactiveerd, of je voert hem zelf uit. Deze lijst toont de flows van elke app.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Er is een VAPID-sleutelpaar geconfigureerd. Gebruikers kunnen zich via hun persoonlijke instellingen aanmelden voor browsermeldingen, die ook worden bezorgd wanneer het browsertabblad gesloten is.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Er is een VAPID-sleutelpaar geconfigureerd. Gebruikers kunnen zich via hun gebruikersinstellingen aanmelden voor browsermeldingen, die ook worden bezorgd wanneer het browsertabblad gesloten is.", "A deactivation request is pending.": "Er is een deactiveringsverzoek in behandeling.", "ADDRESS": "ADRES", "AI Assistant": "AI-assistent", diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 2328542739..1e04d8ccbc 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -618,16 +618,14 @@ private function prepareCredentialRelinkNeeded(INotification $notification, $l): $l->t( 'The connection to %s is no longer accepted by the provider, so anything using it has ' . 'stopped. This cannot be repaired automatically: open Connected accounts in your ' - . 'personal settings and reconnect the account.', + . 'user settings and reconnect the account.', [$provider] ) ); + // The connected accounts live in OpenRegister's own user settings dialog. $notification->setLink( - $this->urlGenerator->linkToRouteAbsolute( - 'settings.PersonalSettings.index', - ['section' => 'additional'] - ) + $this->urlGenerator->linkToRouteAbsolute('openregister.dashboard.page') . '#user-settings' ); $notification->setIcon( diff --git a/lib/Service/Credential/OAuth2Endpoints.php b/lib/Service/Credential/OAuth2Endpoints.php index 02942603ee..793d1b04e4 100644 --- a/lib/Service/Credential/OAuth2Endpoints.php +++ b/lib/Service/Credential/OAuth2Endpoints.php @@ -17,7 +17,7 @@ * WHERE MAY THE FLOW SEND SOMEBODY AFTERWARDS. An attacker-chosen redirect target on * a callback is an open redirect, so a proposed return URL is reduced to a PATH on * this instance and anything carrying its own scheme or host is discarded in favour - * of personal settings. The reduced value is then carried inside the signed state, so + * of the app page. The reduced value is then carried inside the signed state, so * the callback only ever redirects somewhere the instance itself approved rather than * to anything present in the request that came back. * diff --git a/openspec/specs/credential-oauth2-connect/spec.md b/openspec/specs/credential-oauth2-connect/spec.md index ba494092ae..ab364f129d 100644 --- a/openspec/specs/credential-oauth2-connect/spec.md +++ b/openspec/specs/credential-oauth2-connect/spec.md @@ -203,9 +203,9 @@ A failure of this call SHALL NOT undo or alter the connection: the credential st - **WHEN** the identity call is refused, unreachable, or answers with something that is not JSON - **THEN** the stored token set is unchanged and the connection remains usable -### Requirement: A person can connect, see and repair a connection from personal settings +### Requirement: A person can connect, see and repair a connection from user settings -OpenRegister's personal settings SHALL offer a "Connect account" action for every catalogue provider that is an OAuth2 token set, SHALL list the caller's existing connections with a status chip reading active, expired or relink needed, and SHALL offer a Reconnect action on a connection that needs one. The panel SHALL never display a token, a client secret, or an authorization code. +OpenRegister's user settings dialog SHALL offer a "Connect account" action for every catalogue provider that is an OAuth2 token set, SHALL list the caller's existing connections with a status chip reading active, expired or relink needed, and SHALL offer a Reconnect action on a connection that needs one. The panel SHALL never display a token, a client secret, or an authorization code. `@e2e tests/e2e/credential-oauth2-connect.spec.ts` diff --git a/openspec/specs/notificatie-engine/spec.md b/openspec/specs/notificatie-engine/spec.md index 98c7ba3685..322e14f117 100644 --- a/openspec/specs/notificatie-engine/spec.md +++ b/openspec/specs/notificatie-engine/spec.md @@ -255,7 +255,7 @@ Failed notification deliveries MUST be retried with configurable backoff strateg ### Requirement: Users MUST be able to manage their notification preferences -Users MUST be able to turn specific schema-declared notifications on or off (and optionally narrow channels) via a personal settings interface, without affecting other users' preferences. Preferences MUST be stored as override-only values in Nextcloud per-user app config under the `openregister` app (NOT in a `NotificationPreference` or `NotificationSubscription` table). When a user has no override for a `(schema, notification-key)` pair, the schema-declared default applies. Independently of the per-`(schema, notification)` override, a user MAY configure a global delivery window (quiet hours) via `NotificationDeliveryWindowService`, exposed through `GET`/`PUT /api/notification-delivery-window`, stored the same override-only way (a distinct per-user app-config key, `notification_delivery_window`) so a user with no configured window keeps today's immediate-delivery behaviour. +Users MUST be able to turn specific schema-declared notifications on or off (and optionally narrow channels) via the user settings dialog, without affecting other users' preferences. Preferences MUST be stored as override-only values in Nextcloud per-user app config under the `openregister` app (NOT in a `NotificationPreference` or `NotificationSubscription` table). When a user has no override for a `(schema, notification-key)` pair, the schema-declared default applies. Independently of the per-`(schema, notification)` override, a user MAY configure a global delivery window (quiet hours) via `NotificationDeliveryWindowService`, exposed through `GET`/`PUT /api/notification-delivery-window`, stored the same override-only way (a distinct per-user app-config key, `notification_delivery_window`) so a user with no configured window keeps today's immediate-delivery behaviour. #### Scenario: User disables a specific notification - GIVEN schema `meldingen` declares an `object_created` notification (default on) to group `behandelaars` diff --git a/src/components/userSettings/BrowserNotificationsSection.vue b/src/components/userSettings/BrowserNotificationsSection.vue index 7f4de21b91..8dce70320b 100644 --- a/src/components/userSettings/BrowserNotificationsSection.vue +++ b/src/components/userSettings/BrowserNotificationsSection.vue @@ -10,14 +10,12 @@ per Chrome's permission-abuse rules. Shows the current permission state and an unsupported-browser notice when the Push API is unavailable. - TODO mount: this repo registers NO PHP Personal settings class (only - lib/Settings/OpenRegisterAdmin + IntegrationsAdminSettings, both admin). To - surface this toggle to end users, add an OCP\Settings\ISettings personal - settings class + a `…` entry in - appinfo/info.xml and a personal-settings Vue entry point (mirroring - src/settings.js → #settings) that mounts this component. Until that personal - mount point exists the component is unmounted; it is fully functional and only - needs the host shell. See openspec/changes/openregister-web-push-engine. + TODO mount: this component is not mounted yet. The per-person settings live in + the user settings dialog (src/views/settings/UserSettings.vue, opened from the + app navigation and at #user-settings); OpenRegister registers no Nextcloud + personal settings page. To surface this toggle to end users, add it as a + section of that dialog. It is fully functional and only needs that host. + See openspec/changes/openregister-web-push-engine. @spec openspec/changes/archive/2026-10-05-openregister-web-push-engine/tasks.md#task-5.2 --> diff --git a/src/views/settings/sections/PushNotificationsConfiguration.vue b/src/views/settings/sections/PushNotificationsConfiguration.vue index f2a6c4044e..eceaf51611 100644 --- a/src/views/settings/sections/PushNotificationsConfiguration.vue +++ b/src/views/settings/sections/PushNotificationsConfiguration.vue @@ -104,7 +104,7 @@ {{ t( 'openregister', - 'A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.', + 'A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.', ) }}

diff --git a/tests/Unit/Notification/NotifierTest.php b/tests/Unit/Notification/NotifierTest.php index 28ee399af6..a2df03d7bf 100644 --- a/tests/Unit/Notification/NotifierTest.php +++ b/tests/Unit/Notification/NotifierTest.php @@ -109,6 +109,37 @@ public function testPrepareConfigurationUpdateAvailable(): void { } } + /** + * The relink notification opens OpenRegister's own page at the user settings dialog, not Nextcloud's personal settings. + */ + public function testTheRelinkNotificationLinksToTheUserSettingsDialog(): void { + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('openregister'); + $notification->method('getSubject')->willReturn('credential_relink_needed'); + $notification->method('getSubjectParameters')->willReturn(['provider' => 'Exact']); + $notification->method('setParsedSubject')->willReturnSelf(); + $notification->method('setParsedMessage')->willReturnSelf(); + $notification->method('setIcon')->willReturnSelf(); + $link = null; + $notification->method('setLink')->willReturnCallback( + function (string $value) use (&$link, $notification): INotification { + $link = $value; + return $notification; + } + ); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static fn (string $text, array $args = []): string => vsprintf($text, $args)); + $this->factory->method('get')->willReturn($l10n); + $this->urlGenerator->method('imagePath')->willReturn('/apps/openregister/img/app.svg'); + $this->urlGenerator->method('linkToRouteAbsolute') + ->with('openregister.dashboard.page') + ->willReturn('https://example.com/apps/openregister/'); + + $this->notifier->prepare($notification, 'en'); + + $this->assertSame('https://example.com/apps/openregister/#user-settings', $link); + }//end testTheRelinkNotificationLinksToTheUserSettingsDialog() + public function testPrepareConfigurationUpdateWithoutConfigId(): void { $notification = $this->createMock(INotification::class); $notification->method('getApp')->willReturn('openregister'); From 4e900c4582b01e724a4b500a557eea3982a5b1d1 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:19:07 +0200 Subject: [PATCH 19/37] =?UTF-8?q?fix(review):=20#4580=20surf-f1=20?= =?UTF-8?q?=E2=80=94=20Object=20GET=20/logs=20bypasses=20the=20audit-histo?= =?UTF-8?q?ry=20redaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObjectsController::logs() answers a non-admin through ObjectHistoryForReaders::page() (the same read check, doelbinding guard and redaction as the audit-trails route: 404 for an object the caller may not read, no session, request or ipAddress, no property the caller may not see); an administrator keeps the stored history as before. The page keeps the results/total/page/pages/limit/offset shape; the legacy next/prev links are not produced on the reader path. The requirement in audit-trail-immutable/spec.md now names both URLs. The 17 existing testLogs* cases of ObjectsControllerTest, which exercise the stored-history path, now run as an administrator. New ObjectsControllerLogsRedactionTest covers delegation, the 404 and the administrator path; the redaction itself is covered by AuditTrailObjectHistoryForReadersTest. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660832 Check: phpunit tests/Unit/Controller/ObjectsControllerTest.php + ObjectsControllerLogsRedactionTest.php OK 279 tests; tests/Unit/Controller overall: only 'could not find driver' errors (no database driver here); phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Controller/ObjectsController.php | 52 +++++++ openspec/specs/audit-trail-immutable/spec.md | 4 +- .../ObjectsControllerLogsRedactionTest.php | 142 ++++++++++++++++++ .../Unit/Controller/ObjectsControllerTest.php | 34 +++++ 4 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Controller/ObjectsControllerLogsRedactionTest.php diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php index a9983c8c9c..6b4678228a 100644 --- a/lib/Controller/ObjectsController.php +++ b/lib/Controller/ObjectsController.php @@ -31,6 +31,8 @@ use DateTimeImmutable; use OCA\OpenRegister\Controller\Trait\ResolvesRegisterAndSchemaTrait; use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Service\Audit\ObjectHistoryForReaders; +use OCA\OpenRegister\Service\Audit\PurposeRefusedException; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Register; use OCA\OpenRegister\Db\RegisterMapper; @@ -4782,6 +4784,14 @@ public function used(string $id, string $register, string $schema, ObjectService * @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md */ public function logs(string $id, string $register, string $schema, ObjectService $objectService): JSONResponse { + // Whoever may read the object reads its history, redacted like the + // audit-trails route: a non-admin is answered by ObjectHistoryForReaders + // (404 when the object GET would be, the doelbinding guard, and no + // `session`, `request`, `ipAddress` or property the caller may not see). + if ($this->isCurrentUserAdmin() === false) { + return $this->logsForReader(register: $register, schema: $schema, id: $id); + } + // Set the register and schema context first. $objectService->setRegister(register: $register); $objectService->setSchema(schema: $schema); @@ -4870,6 +4880,48 @@ public function logs(string $id, string $register, string $schema, ObjectService ); }//end logs() + /** + * One object's history for a non-admin, when they may read the object. + * + * @param string $register The register slug or identifier + * @param string $schema The schema slug or identifier + * @param string $id The object ID or UUID + * + * @return JSONResponse 200 with the redacted page, 404 when the object is not readable to this caller, + * or the doelbinding refusal. + * + * @spec openspec/specs/audit-trail-immutable/spec.md#requirement-the-history-of-one-object-is-readable-by-whoever-may-read-that-object + */ + private function logsForReader(string $register, string $schema, string $id): JSONResponse { + $config = $this->getConfig(_register: $register, _schema: $schema); + $search = $config['_search']; + + try { + $page = $this->container->get(ObjectHistoryForReaders::class)->page( + register: $register, + schema: $schema, + id: $id, + params: [ + 'limit' => $config['limit'], + 'offset' => $config['offset'], + 'page' => $config['page'], + 'filters' => [], + 'sort' => [], + 'search' => ($search === null ? null : (string)$search), + ] + ); + } catch (PurposeRefusedException $refusal) { + return new JSONResponse(data: $refusal->toResponseBody(), statusCode: $refusal->getStatusCode()); + } + + if ($page === null) { + // The answer the object GET gives a caller who may not read the object. + return new JSONResponse(data: ['message' => 'Object not found'], statusCode: 404); + } + + return new JSONResponse(data: $page); + }//end logsForReader() + /** * Lock an object * diff --git a/openspec/specs/audit-trail-immutable/spec.md b/openspec/specs/audit-trail-immutable/spec.md index 379c0747a6..3a2f3bb54f 100644 --- a/openspec/specs/audit-trail-immutable/spec.md +++ b/openspec/specs/audit-trail-immutable/spec.md @@ -275,7 +275,9 @@ object, and the admin-only index remains the only surface that carries them. ### Requirement: The history of one object is readable by whoever may read that object -The per-object history, `GET /api/objects/{register}/{schema}/{id}/audit-trails`, +The per-object history, `GET /api/objects/{register}/{schema}/{id}/audit-trails` +and its older sibling `GET /api/objects/{register}/{schema}/{id}/logs` (the same +answer, for the same caller, through the same redaction), SHALL be readable by a non-admin caller if and only if that caller may read the object itself (decision 14 Oct 2026). Readability SHALL be decided by the read path of the object GET, `ObjectService::find()` with RBAC and multitenancy on, diff --git a/tests/Unit/Controller/ObjectsControllerLogsRedactionTest.php b/tests/Unit/Controller/ObjectsControllerLogsRedactionTest.php new file mode 100644 index 0000000000..495f6cca9c --- /dev/null +++ b/tests/Unit/Controller/ObjectsControllerLogsRedactionTest.php @@ -0,0 +1,142 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/audit-trail-immutable/spec.md#requirement-the-history-of-one-object-is-readable-by-whoever-may-read-that-object + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Controller; + +use OCA\OpenRegister\Controller\ObjectsController; +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Audit\ObjectHistoryForReaders; +use OCA\OpenRegister\Service\ExportService; +use OCA\OpenRegister\Service\ImportService; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\WebhookService; +use OCP\App\IAppManager; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * The legacy /logs route no longer returns the raw audit rows to a reader who is not an administrator. + */ +class ObjectsControllerLogsRedactionTest extends TestCase { + + private ObjectsController $controller; + + private ObjectHistoryForReaders&MockObject $history; + + private ObjectService&MockObject $objectService; + + private IGroupManager&MockObject $groupManager; + + /** + * Wire the controller around a mocked reader history. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->history = $this->createMock(ObjectHistoryForReaders::class); + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + fn (string $id): ?object => ($id === ObjectHistoryForReaders::class ? $this->history : null) + ); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + $this->groupManager = $this->createMock(IGroupManager::class); + $request = $this->createMock(IRequest::class); + $request->method('getParams')->willReturn(['limit' => 5, '_search' => 'x']); + $this->objectService = $this->createMock(ObjectService::class); + + $this->controller = new ObjectsController( + 'openregister', + $request, + $this->createMock(IAppConfig::class), + $this->createMock(IAppManager::class), + $container, + $this->createMock(RegisterMapper::class), + $this->createMock(SchemaMapper::class), + $this->createMock(AuditTrailMapper::class), + $this->objectService, + $session, + $this->groupManager, + $this->createMock(ExportService::class), + $this->createMock(ImportService::class), + $this->createMock(WebhookService::class), + $this->createMock(LoggerInterface::class) + ); + }//end setUp() + + /** + * A non-admin gets the redacted page the reader history builds, and the raw log read is never made. + * + * @return void + */ + public function testANonAdminGetsTheRedactedHistory(): void { + $this->groupManager->method('getUserGroupIds')->willReturn(['staff']); + $page = ['results' => [['action' => 'update', 'changed' => ['title' => ['old' => 'a', 'new' => 'b']]]], 'total' => 1, 'page' => null, 'pages' => 1.0, 'limit' => 5, 'offset' => null]; + $this->history->expects($this->once())->method('page') + ->with('reg', 'sch', 'obj-1', $this->callback(static fn (array $params): bool => $params['limit'] === 5 && $params['search'] === 'x' && $params['filters'] === [])) + ->willReturn($page); + $this->objectService->expects($this->never())->method('getLogs'); + + $response = $this->controller->logs(id: 'obj-1', register: 'reg', schema: 'sch', objectService: $this->objectService); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame($page, $response->getData()); + $this->assertArrayNotHasKey('ipAddress', $response->getData()['results'][0]); + }//end testANonAdminGetsTheRedactedHistory() + + /** + * An object the caller may not read answers 404, like the audit-trails route and the object GET. + * + * @return void + */ + public function testAnUnreadableObjectIs404(): void { + $this->groupManager->method('getUserGroupIds')->willReturn(['staff']); + $this->history->method('page')->willReturn(null); + $this->objectService->expects($this->never())->method('getLogs'); + + $response = $this->controller->logs(id: 'obj-1', register: 'reg', schema: 'sch', objectService: $this->objectService); + + $this->assertSame(404, $response->getStatus()); + }//end testAnUnreadableObjectIs404() + + /** + * An administrator still reads the stored history as before: the reader history is not asked. + * + * @return void + */ + public function testAnAdministratorKeepsTheStoredHistory(): void { + $this->groupManager->method('getUserGroupIds')->willReturn(['admin']); + $this->history->expects($this->never())->method('page'); + $this->objectService->method('setRegister')->willReturnSelf(); + $this->objectService->method('setSchema')->willReturnSelf(); + $this->objectService->method('find')->willReturn(null); + + $response = $this->controller->logs(id: 'obj-1', register: 'reg', schema: 'sch', objectService: $this->objectService); + + $this->assertSame(404, $response->getStatus()); + }//end testAnAdministratorKeepsTheStoredHistory() +}//end class diff --git a/tests/Unit/Controller/ObjectsControllerTest.php b/tests/Unit/Controller/ObjectsControllerTest.php index 10456b4da8..93c5ea60b1 100644 --- a/tests/Unit/Controller/ObjectsControllerTest.php +++ b/tests/Unit/Controller/ObjectsControllerTest.php @@ -993,6 +993,8 @@ public function testUsedThrowsWhenNotFound(): void { // --- logs() tests --- public function testLogsReturns404WhenObjectNotFound(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $this->request->method('getParams')->willReturn([]); $this->request->method('getRequestUri')->willReturn('/api/objects/1/2/uuid-123/logs'); @@ -1009,6 +1011,8 @@ public function testLogsReturns404WhenObjectNotFound(): void { } public function testLogsThrowsWhenFindFails(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $this->request->method('getParams')->willReturn([]); $this->objectService->method('setSchema')->willReturnSelf(); @@ -2092,6 +2096,8 @@ public function testObjectsReturns404WhenRegisterNotFoundViaQueryParam(): void { * Test logs() returns 404 when register does not match (with message key check). */ public function testLogsReturns404WhenRegisterDoesNotMatchWithMessage(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $objectEntity = new \OCA\OpenRegister\Db\ObjectEntity(); $objectEntity->setUuid('uuid-123'); $objectEntity->setRegister('99'); // different register @@ -2118,6 +2124,8 @@ public function testLogsReturns404WhenRegisterDoesNotMatchWithMessage(): void { * Test logs() returns 404 when schema does not match (with message content). */ public function testLogsReturns404WhenSchemaDoesNotMatchWithMessage(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $objectEntity = new \OCA\OpenRegister\Db\ObjectEntity(); $objectEntity->setUuid('uuid-123'); $objectEntity->setRegister('1'); @@ -2444,6 +2452,8 @@ public function testUnlockReturns404WhenObjectNotFound(): void { // ========================================================================= public function testLogsReturnsPaginatedLogsOnSuccess(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $objectEntity = new \OCA\OpenRegister\Db\ObjectEntity(); $objectEntity->setUuid('uuid-123'); $objectEntity->setRegister('1'); @@ -3461,6 +3471,8 @@ public function testGetObjectVectorizationCountWithViews(): void { // ========================================================================= public function testLogsThrowsWhenRegisterNotFound(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $this->request->method('getParams')->willReturn([]); $this->objectService->method('setRegister') @@ -3471,6 +3483,8 @@ public function testLogsThrowsWhenRegisterNotFound(): void { } public function testLogsThrowsWhenSchemaNotFound(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $this->request->method('getParams')->willReturn([]); $this->objectService->method('setRegister')->willReturnSelf(); @@ -4627,6 +4641,8 @@ public function testIndexHandlesObjectEntityResultsInEmptyStripping(): void { // ========================================================================= public function testLogsMatchesSchemaBySlug(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $this->markTestSkipped(self::SCHEMA_SHAPE_SKIP); // Use a mock to return an array from getSchema (real entity is typed ?string) $objectEntity = $this->getMockBuilder(\OCA\OpenRegister\Db\ObjectEntity::class) @@ -4666,6 +4682,8 @@ public function testLogsMatchesSchemaBySlug(): void { * @return void */ public function testLogsReturns200WhenSchemaMatchesAsString(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $objectEntity = new \OCA\OpenRegister\Db\ObjectEntity(); $objectEntity->setUuid('uuid-123'); $objectEntity->setRegister('1'); @@ -4688,6 +4706,8 @@ public function testLogsReturns200WhenSchemaMatchesAsString(): void { }//end testLogsReturns200WhenSchemaMatchesAsString() public function testLogsReturns404WhenSchemaDoesNotMatch(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $objectEntity = new \OCA\OpenRegister\Db\ObjectEntity(); $objectEntity->setUuid('uuid-123'); $objectEntity->setRegister('1'); @@ -4711,6 +4731,8 @@ public function testLogsReturns404WhenSchemaDoesNotMatch(): void { } public function testLogsReturns404WhenRegisterDoesNotMatch(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $objectEntity = new \OCA\OpenRegister\Db\ObjectEntity(); $objectEntity->setUuid('uuid-123'); $objectEntity->setRegister('99'); // different register @@ -4736,6 +4758,8 @@ public function testLogsReturns404WhenRegisterDoesNotMatch(): void { // ========================================================================= public function testLogsMatchesSchemaAsObject(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $this->markTestSkipped(self::SCHEMA_SHAPE_SKIP); $schemaObj = new \stdClass(); $schemaObj->id = '2'; @@ -4771,6 +4795,8 @@ public function testLogsMatchesSchemaAsObject(): void { // ========================================================================= public function testLogsMatchesSchemaAsObjectById(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $this->markTestSkipped(self::SCHEMA_SHAPE_SKIP); $schemaObj = new \stdClass(); $schemaObj->id = '2'; @@ -4805,6 +4831,8 @@ public function testLogsMatchesSchemaAsObjectById(): void { // ========================================================================= public function testLogsMatchesSchemaAsArrayById(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $this->markTestSkipped(self::SCHEMA_SHAPE_SKIP); // Use a mock to return an array from getSchema (real entity is typed ?string) $objectEntity = $this->getMockBuilder(\OCA\OpenRegister\Db\ObjectEntity::class) @@ -4836,6 +4864,8 @@ public function testLogsMatchesSchemaAsArrayById(): void { // ========================================================================= public function testLogsWithPaginationParams(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $objectEntity = new \OCA\OpenRegister\Db\ObjectEntity(); $objectEntity->setUuid('uuid-123'); $objectEntity->setRegister('1'); @@ -5378,6 +5408,8 @@ public function testContractsWithNonUnderscoreOffsetParam(): void { // ========================================================================= public function testLogsReturns404OnDbException(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $this->request->method('getParams')->willReturn([]); $this->objectService->method('setSchema')->willReturnSelf(); @@ -5397,6 +5429,8 @@ public function testLogsReturns404OnDbException(): void { // ========================================================================= public function testLogsWithLegacyParams(): void { + // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest). + $this->setupAdminUser(); $objectEntity = new \OCA\OpenRegister\Db\ObjectEntity(); $objectEntity->setUuid('uuid-123'); $objectEntity->setRegister('1'); From c967b2d6bdd9522f30225754d5429d3cdd056539 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:20:50 +0200 Subject: [PATCH 20/37] =?UTF-8?q?fix(review):=20#4580=20eng-f8=20=E2=80=94?= =?UTF-8?q?=20Task=20wake=20still=20overwrites=20another=20listener's=20si?= =?UTF-8?q?gnal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlowRunService::signal() merges the new payload over the signal still waiting on the suspended run (array_merge: keys of both survive, a key both set takes the later value) instead of assigning it outright, so the FlowTaskBridge outcome bag and another listener's payload no longer depend on listener order. A consumed signal is still removed by persistResult(), so nothing from an earlier question reaches the merge. FlowRunServiceTest gets a two-signal case. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660510 Sweep: signal() is the only writer of the signal context key (RegistryStepDispatcher only reads and unsets it). Check: phpunit tests/Unit/Service/Flow OK 1525 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Flow/FlowRunService.php | 14 +++++++++++++ .../Unit/Service/Flow/FlowRunServiceTest.php | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php index f7da616e9d..c763a69040 100644 --- a/lib/Service/Flow/FlowRunService.php +++ b/lib/Service/Flow/FlowRunService.php @@ -881,6 +881,9 @@ public function retry(FlowRun $run): ?FlowRun { * the seam exists to close, silently: the wrong person's answer arrives * correctly formatted. * + * Signals that arrive before the run wakes are merged, not replaced: a later + * signal adds its keys to the waiting one and wins only a key both carry. + * * @param FlowRun $run The suspended run to wake. * @param array $payload What the signaller wants the run to know. * @@ -896,7 +899,18 @@ public function signal(FlowRun $run, array $payload = []): ?FlowRun { return null; } + // Two listeners on one event may each signal the same suspended run. + // The signal still waiting is kept and the new payload is merged over + // it (a key both set takes the later value), so the answer does not + // depend on which listener ran last; a consumed signal is removed by + // persistResult(), so nothing left over from an earlier question can + // reach here. $context = ($run->getContext() ?? []); + $waiting = ($context[self::SIGNAL_CONTEXT_KEY] ?? null); + if (is_array($waiting) === true) { + $payload = array_merge($waiting, $payload); + } + $context[self::SIGNAL_CONTEXT_KEY] = $payload; $run->setContext($context); diff --git a/tests/Unit/Service/Flow/FlowRunServiceTest.php b/tests/Unit/Service/Flow/FlowRunServiceTest.php index e3f22b93e2..3cd8c2a271 100644 --- a/tests/Unit/Service/Flow/FlowRunServiceTest.php +++ b/tests/Unit/Service/Flow/FlowRunServiceTest.php @@ -475,6 +475,27 @@ public function testAReAskLoopThatReceivesTheAnswerTerminates(): void { $this->assertSame(1, $this->doneBranch->calls); }//end testAReAskLoopThatReceivesTheAnswerTerminates() + /** + * Two signals on one suspended run keep the keys of both, whichever came last. + * + * @return void + */ + public function testTwoSignalsOnASuspendedRunAreMerged(): void { + $flow = ['id' => 'f1', 'nodes' => [['id' => 'first', 'type' => 'test.ask']], 'edges' => []]; + $run = $this->service->queue('f1', user: 'alice'); + $run = $this->service->execute($run, $flow, new RunSubject()); + $this->assertSame(FlowRun::STATUS_SUSPENDED, $run->getStatus()); + + $run = $this->service->signal($run, ['decision' => 'approved', 'taskId' => 'task-1']); + $run = $this->service->signal($run, ['completedBy' => 'alice', 'decision' => 'approved-later']); + + $this->assertNotNull($run); + $signal = $run->getContext()[FlowRunService::SIGNAL_CONTEXT_KEY]; + $this->assertSame('task-1', $signal['taskId'], 'the first listener\'s key survives'); + $this->assertSame('alice', $signal['completedBy'], 'the second listener\'s key is added'); + $this->assertSame('approved-later', $signal['decision'], 'a key both set takes the later value'); + }//end testTwoSignalsOnASuspendedRunAreMerged() + /** * THE RESUME ANSWER IS SCOPED TO THE NODE THAT SUSPENDED — through the * worker path. `signal()` then `execute()` is exactly what the resume From 1dfdcd386f9b9d16bc2b59b0274c1f18e0d6952d Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:24:04 +0200 Subject: [PATCH 21/37] =?UTF-8?q?fix(review):=20#4580=20eng-f9=20=E2=80=94?= =?UTF-8?q?=20Decision=20ownership=20check=20is=20skipped=20in=20two=20cas?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decide() now treats a relation that names a decision whose record cannot be found as owned by nobody but a supervisor (a synthetic decision with no decider goes through DecisionOwnership::refusal, so a supervisor, an administrator or the owning app's listener still gets through and a reviewer is refused), and links the new decision with a compare-and-swap: EntityRelationMapper::linkDecision() gains expectedUuid and guarded arguments, and a guarded link writes only while the stored link is still the one read, otherwise it throws DecisionConflictException, which decide() rolls back and answers as decision-owned. Two reviewers taking a first decision at once can no longer both link theirs. New cases in DecisionOwnershipTest: dangling link refused for a reviewer and allowed for a supervisor, and a lost link race refused. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660525 Check: phpunit tests/Unit/Controller/DecisionOwnershipTest + EntityRelationsControllerTest + tests/Unit/Service/Decision + EntityRelationMapperUpdateDecisionMetadataTest OK 57 tests; phpcs on the three lib files clean. The guarded SQL update itself needs a database and was not run here. Co-Authored-By: Claude Opus 5.5 --- lib/Db/EntityRelationMapper.php | 27 +++++++++++- lib/Exception/DecisionConflictException.php | 31 +++++++++++++ .../Decision/RelationDecisionService.php | 33 +++++++++++--- .../Unit/Controller/DecisionOwnershipTest.php | 43 ++++++++++++++++++- 4 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 lib/Exception/DecisionConflictException.php diff --git a/lib/Db/EntityRelationMapper.php b/lib/Db/EntityRelationMapper.php index c6b37cbd16..761d6e8b81 100644 --- a/lib/Db/EntityRelationMapper.php +++ b/lib/Db/EntityRelationMapper.php @@ -23,6 +23,7 @@ use DateTime; use OCA\OpenRegister\Event\EntityRelationDecisionUpdatedEvent; use OCA\OpenRegister\Exception\CustomValidationException; +use OCA\OpenRegister\Exception\DecisionConflictException; use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; @@ -948,14 +949,38 @@ private function emitDecisionMetadataAuditEntry( * `openregister_decisions` (decision 197); the relation only points at it. * The audit trail of the verdict stays with `updateDecisionMetadata()`. * + * With `$guarded`, the link is a compare-and-swap: it is written only while + * the stored link is still `$expectedUuid` (null: none), so two reviewers + * taking a first decision at once cannot both link theirs. + * * @param EntityRelation $relation The relation. * @param string $decisionUuid The decision's uuid. + * @param string|null $expectedUuid The link the caller read before deciding; only read when guarded. + * @param bool $guarded Whether to write only while the stored link is still `$expectedUuid`. * * @return EntityRelation The stored relation. * + * @throws DecisionConflictException When guarded and the stored link changed in the meantime. + * * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 */ - public function linkDecision(EntityRelation $relation, string $decisionUuid): EntityRelation { + public function linkDecision(EntityRelation $relation, string $decisionUuid, ?string $expectedUuid = null, bool $guarded = false): EntityRelation { + if ($guarded === true) { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('decision_uuid', $qb->createNamedParameter($decisionUuid)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($relation->getId(), IQueryBuilder::PARAM_INT))); + if ($expectedUuid === null || $expectedUuid === '') { + $qb->andWhere($qb->expr()->orX($qb->expr()->isNull('decision_uuid'), $qb->expr()->eq('decision_uuid', $qb->createNamedParameter('')))); + } else { + $qb->andWhere($qb->expr()->eq('decision_uuid', $qb->createNamedParameter($expectedUuid))); + } + + if ($qb->executeStatement() === 0) { + throw new DecisionConflictException(message: 'The relation was decided by somebody else in the meantime.'); + } + } + $relation->setDecisionUuid($decisionUuid); return $this->update(entity: $relation); diff --git a/lib/Exception/DecisionConflictException.php b/lib/Exception/DecisionConflictException.php new file mode 100644 index 0000000000..a9a905877c --- /dev/null +++ b/lib/Exception/DecisionConflictException.php @@ -0,0 +1,31 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Exception + * @package OCA\OpenRegister\Exception + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use RuntimeException; + +/** + * Thrown by the guarded decision link when somebody else decided first. + */ +class DecisionConflictException extends RuntimeException { +}//end class diff --git a/lib/Service/Decision/RelationDecisionService.php b/lib/Service/Decision/RelationDecisionService.php index 3e132599a0..8ca3b3f8ad 100644 --- a/lib/Service/Decision/RelationDecisionService.php +++ b/lib/Service/Decision/RelationDecisionService.php @@ -32,6 +32,7 @@ use OCA\OpenRegister\Db\EntityRelation; use OCA\OpenRegister\Db\EntityRelationMapper; use OCA\OpenRegister\Exception\CustomValidationException; +use OCA\OpenRegister\Exception\DecisionConflictException; use OCP\IDBConnection; use OCP\IUser; use Throwable; @@ -91,7 +92,7 @@ public function __construct( * * @return array{relation: EntityRelation, decision: Decision|null} The relation and its current decision. * - * @throws DecisionOwnedException When the decision belongs to someone else. + * @throws DecisionOwnedException When the decision belongs to someone else, its record is missing, or another reviewer decided first. * @throws CustomValidationException When the body is malformed. * * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 @@ -102,10 +103,21 @@ public function decide(EntityRelation $relation, array $body, IUser $actor): arr unset($body[self::NOTE_FIELD]); $actorId = $actor->getUID(); - $current = $this->decisions->find(uuid: $relation->getDecisionUuid()); - if ($current !== null) { + $linkedUuid = $relation->getDecisionUuid(); + $current = $this->decisions->find(uuid: $linkedUuid); + $owned = $current; + if ($owned === null && $linkedUuid !== null && $linkedUuid !== '') { + // The relation names a decision whose record cannot be found. + // DecisionVisibility hides that state; here it belongs to nobody + // but a supervisor, so a reviewer cannot take it over by the gap. + $owned = new Decision(); + $owned->setUuid($linkedUuid); + $owned->setDecidedBy(''); + } + + if ($owned !== null) { $resolved = $this->policies->forRelation(relation: $relation); - $refusal = $this->ownership->refusal(decision: $current, actorId: $actorId, policy: $resolved['policy'], subject: $resolved['subject']); + $refusal = $this->ownership->refusal(decision: $owned, actorId: $actorId, policy: $resolved['policy'], subject: $resolved['subject']); if ($refusal !== null) { throw new DecisionOwnedException(refusal: $refusal); } @@ -140,8 +152,19 @@ public function decide(EntityRelation $relation, array $body, IUser $actor): arr note: $note, supersedes: $current?->getUuid() ); - $updated = $this->relationMapper->linkDecision(relation: $updated, decisionUuid: (string)$decision->getUuid()); + // Compare-and-swap on the link read above: two reviewers taking a + // first decision at once cannot both link theirs (the second finds + // the link taken and is refused). + $updated = $this->relationMapper->linkDecision( + relation: $updated, + decisionUuid: (string)$decision->getUuid(), + expectedUuid: $linkedUuid, + guarded: true + ); $this->db->commit(); + } catch (DecisionConflictException) { + $this->db->rollBack(); + throw new DecisionOwnedException(refusal: ['error' => 'decision-owned', 'decision' => null]); } catch (Throwable $error) { $this->db->rollBack(); throw $error; diff --git a/tests/Unit/Controller/DecisionOwnershipTest.php b/tests/Unit/Controller/DecisionOwnershipTest.php index f98caf9e62..9b2c2dae0d 100644 --- a/tests/Unit/Controller/DecisionOwnershipTest.php +++ b/tests/Unit/Controller/DecisionOwnershipTest.php @@ -95,6 +95,13 @@ class DecisionOwnershipTest extends TestCase { */ private array $groups = ['coord' => ['woo-coordinator'], 'root' => ['admin']]; + /** + * Whether the guarded link finds the link already taken, as a concurrent first decision would. + * + * @var bool + */ + private bool $linkConflict = false; + /** * Set up a relation every reviewer may write. * @@ -141,7 +148,11 @@ static function (EntityRelation $relation, array $fields): EntityRelation { } ); $relations->method('linkDecision')->willReturnCallback( - static function (EntityRelation $relation, string $decisionUuid): EntityRelation { + function (EntityRelation $relation, string $decisionUuid, ?string $expectedUuid = null, bool $guarded = false): EntityRelation { + if ($guarded === true && $this->linkConflict === true) { + throw new \OCA\OpenRegister\Exception\DecisionConflictException('taken'); + } + $relation->setDecisionUuid($decisionUuid); return $relation; } @@ -162,7 +173,7 @@ static function (EntityRelation $relation, string $decisionUuid): EntityRelation $groups->method('isInGroup')->willReturnCallback(fn (string $id, string $gid): bool => in_array($gid, ($this->groups[$id] ?? []), true)); $decisionMapper = $this->createMock(DecisionMapper::class); - $decisionMapper->method('findByUuid')->willReturnCallback(fn (string $uuid): Decision => $this->decisions[$uuid]); + $decisionMapper->method('findByUuid')->willReturnCallback(fn (string $uuid): Decision => ($this->decisions[$uuid] ?? throw new \OCP\AppFramework\Db\DoesNotExistException('gone'))); $decisionMapper->method('insert')->willReturnCallback( function (Decision $decision): Decision { $decision->setUuid('d-'.(count($this->decisions) + 1)); @@ -304,6 +315,34 @@ public function testAnAdministratorMay(): void { * * @return void */ + public function testADanglingDecisionLinkIsRefusedForAReviewer(): void { + $this->schemaConfiguration = ['x-openregister-review' => ['ownership' => 'decider', 'supervisors' => ['woo-coordinator']]]; + // The relation names a decision whose record is gone. + $this->relation->setDecisionUuid('d-gone'); + + $response = $this->controllerAs('alice', ['bases' => ['A']])->update(7); + $this->assertSame(403, $response->getStatus()); + $this->assertSame('decision-owned', $response->getData()['error']); + $this->assertEmpty($this->decisions); + + // A supervisor may take it over. + $this->assertSame(200, $this->controllerAs('coord', ['bases' => ['A']])->update(7)->getStatus()); + }//end testADanglingDecisionLinkIsRefusedForAReviewer() + + /** + * The link is guarded: when somebody else linked a decision first, the second is refused and nothing is recorded as linked. + */ + public function testASecondConcurrentFirstDecisionIsRefused(): void { + $this->schemaConfiguration = ['x-openregister-review' => ['ownership' => 'decider']]; + $this->linkConflict = true; + + $response = $this->controllerAs('alice', ['bases' => ['A']])->update(7); + + $this->assertSame(403, $response->getStatus()); + $this->assertSame('decision-owned', $response->getData()['error']); + $this->assertNull($this->relation->getDecisionUuid()); + }//end testASecondConcurrentFirstDecisionIsRefused() + public function testAnUndecidedRelationIsOpen(): void { $response = $this->controllerAs(uid: 'p.bakker', body: ['skipAnonymization' => false])->update(7); From 09d5d985fd2f375f90a82f7a8d2db05a9def1e8a Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:24:44 +0200 Subject: [PATCH 22/37] =?UTF-8?q?fix(review):=20#4580=20eng-f10=20?= =?UTF-8?q?=E2=80=94=20Failure=20to=20arm=20a=20statutory=20expiry=20timer?= =?UTF-8?q?=20is=20only=20logged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CasePlanItemTimers::arm() now logs at error level and rethrows when the timer of a statutory (wettelijk) deadline is refused, so onEntered() raises it instead of letting the item sit active past a legal term with nothing to end it; advisory and service-norm timers stay a warning and a 0. New test: a statutory arm failure is raised, a service-norm one is not. Note for the author: CaseItemTimerListener::handle() calls onEntered() from an event listener, and Nextcloud's event dispatcher logs and swallows a listener's exception, so whether the transition itself fails depends on the caller of the transition; a visible marker on the item would need a schema field and is not added here. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660540 Check: phpunit tests/Unit/Service/Case + tests/Unit/Listener OK 608 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Case/CasePlanItemTimers.php | 17 +++++++++++++++ .../Service/Case/CasePlanItemTimersTest.php | 21 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/lib/Service/Case/CasePlanItemTimers.php b/lib/Service/Case/CasePlanItemTimers.php index 34ce0f3b86..56cfc12947 100644 --- a/lib/Service/Case/CasePlanItemTimers.php +++ b/lib/Service/Case/CasePlanItemTimers.php @@ -85,6 +85,8 @@ public function __construct( * * @return int How many timers were armed. * + * @throws Throwable When the timer of a statutory (wettelijk) expiry could not be armed; any other refusal is logged. + * * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ public function onEntered(CaseItem $item, ?string $actor, ?DateTimeInterface $now = null): int { @@ -171,6 +173,8 @@ public function itemToTerminate(FlowTimerFiredEvent $event): ?string { * * @return boolean True when armed. * + * @throws Throwable When the timer of a statutory (wettelijk) deadline could not be armed. + * * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadline, ?string $actor, DateTimeImmutable $now): bool { @@ -215,6 +219,19 @@ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadlin return true; } catch (Throwable $failure) { + if ($config['legalEffect'] === FlowTimer::LEGAL_WETTELIJK) { + // A statutory expiry that is not armed is a term that never + // runs: the item would stay active past a legal deadline with + // nothing to end it. That is not a warning to log and move on + // from; it is raised to the caller of the transition. + $this->logger->error( + '[CasePlanItemTimers] The timer of a statutory plan-item deadline could not be armed: ' . $failure->getMessage(), + ['item' => $item->getUuid(), 'purpose' => $purpose, 'exception' => $failure] + ); + + throw $failure; + } + $this->logger->warning( '[CasePlanItemTimers] A plan-item timer was refused; the transition stands: ' . $failure->getMessage(), ['item' => $item->getUuid(), 'purpose' => $purpose, 'exception' => $failure] diff --git a/tests/Unit/Service/Case/CasePlanItemTimersTest.php b/tests/Unit/Service/Case/CasePlanItemTimersTest.php index 8df6af8a82..40084031d0 100644 --- a/tests/Unit/Service/Case/CasePlanItemTimersTest.php +++ b/tests/Unit/Service/Case/CasePlanItemTimersTest.php @@ -162,6 +162,27 @@ static function (array $config) use (&$configs): FlowTimer { $this->assertSame(0, $this->service()->onEntered(item: $far, actor: null, now: $now)); }//end testEdgesNeverBlockTheTransition() + /** + * A refused timer of a statutory expiry is raised, not logged away; a service-norm or advisory one stays a warning. + * + * @return void + */ + public function testARefusedStatutoryTimerIsRaised(): void { + $now = new DateTimeImmutable('2026-10-10T10:00:00+00:00'); + $statutory = CaseFixtures::row(id: 9, key: 'stat', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $statutory->setExpiresAt(new DateTime('2026-10-20T10:00:00+00:00')); + $statutory->setPlanSettings(['statutory' => ['stat']]); + $norm = CaseFixtures::row(id: 10, key: 'norm', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $norm->setExpiresAt(new DateTime('2026-10-20T10:00:00+00:00')); + $this->timers->method('arm')->willThrowException(new RuntimeException('calendar unknown')); + + $this->assertSame(0, $this->service()->onEntered(item: $norm, actor: null, now: $now), 'a service norm stays a warning'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('calendar unknown'); + $this->service()->onEntered(item: $statutory, actor: null, now: $now); + }//end testARefusedStatutoryTimerIsRaised() + /** * Reaching a terminal state cancels the item's open timers; a failure to * cancel is logged, never thrown. From 71f4796dc41aaa29d22de216b9fd94a80bbdaaa0 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:25:32 +0200 Subject: [PATCH 23/37] =?UTF-8?q?fix(review):=20#4580=20plat-f4=20?= =?UTF-8?q?=E2=80=94=20Aborted=20delete=20or=20rename=20reads=20as=20succe?= =?UTF-8?q?ss=20to=20internal=20callers=20(delete=20part)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeleteFileHandler::deleteFile() reads the node's id before delete() and asks the root folder for it afterwards; a node that is still there (Nextcloud ignores a listener's abortOperation() through the Node API, as the frozen-object guard uses) is logged and reported false instead of true, so FileService::deleteFile() callers such as the example-set remover and the merge no longer believe a file is gone that is not. New test: delete() that leaves the node behind answers false. The rename/move handlers are not changed here (left to the author, see the hand-back note), and the ExampleSetRemoveLiveTest case from Done-when was not added. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660694 Check: phpunit tests/Unit/Service/File OK 489 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/File/DeleteFileHandler.php | 14 ++++++++++++++ tests/Unit/Service/File/DeleteFileHandlerTest.php | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/Service/File/DeleteFileHandler.php b/lib/Service/File/DeleteFileHandler.php index 16cbb58ef7..abb85e0c50 100644 --- a/lib/Service/File/DeleteFileHandler.php +++ b/lib/Service/File/DeleteFileHandler.php @@ -125,8 +125,10 @@ public function deleteFile(Node|string|int $file, ?ObjectEntity $object = null): throw new NotPermittedException("File {$file->getName()} is not deletable by the current session"); } + $fileId = $file->getId(); try { $file->delete(); + $survives = ($this->rootFolder->getFirstNodeById($fileId) !== null); } catch (Exception $e) { $this->logger->error( message: '[DeleteFileHandler] Failed to delete file: ' . $e->getMessage(), @@ -135,6 +137,18 @@ public function deleteFile(Node|string|int $file, ?ObjectEntity $object = null): return false; } + if ($survives === true) { + // Through the Node API Nextcloud ignores an operation a listener + // aborted (the frozen-object guard does): delete() returns, the file + // is still there. Saying true here makes every caller (the example + // set remover, the merge) believe a file is gone that is not. + $this->logger->warning( + message: '[DeleteFileHandler] The file was not deleted: it is still there after delete() (an operation was aborted, for instance by a frozen object).', + context: ['file' => __FILE__, 'line' => __LINE__, 'fileId' => $fileId] + ); + return false; + } + return true; }//end deleteFile() }//end class diff --git a/tests/Unit/Service/File/DeleteFileHandlerTest.php b/tests/Unit/Service/File/DeleteFileHandlerTest.php index 3f3f506627..bf69af2d5f 100644 --- a/tests/Unit/Service/File/DeleteFileHandlerTest.php +++ b/tests/Unit/Service/File/DeleteFileHandlerTest.php @@ -123,6 +123,19 @@ public function testDeleteRefusedWithoutDeletePermission(): void { // deleteFile - a Node is deleted as a Node // ========================================================================= + /** + * A delete that returns without removing the node (an aborted operation) is reported as not deleted. + */ + public function testADeleteThatLeavesTheNodeBehindIsReportedFalse(): void { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn(42); + $file->method('isDeletable')->willReturn(true); + $file->expects($this->once())->method('delete'); + $this->rootFolder->method('getFirstNodeById')->with(42)->willReturn($file); + + $this->assertFalse($this->handler->deleteFile(file: $file, object: $this->createMock(ObjectEntity::class))); + }//end testADeleteThatLeavesTheNodeBehindIsReportedFalse() + /** * A Node argument MUST be deleted directly, never cast to string. * From 224da09c4850e0f09e7846dfab93f7c7a3074616 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:27:01 +0200 Subject: [PATCH 24/37] =?UTF-8?q?fix(review):=20#4580=20eng-f3=20=E2=80=94?= =?UTF-8?q?=20@spec=20on=20FormIdempotencyStore::release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec-coverage gate flagged the new release() method; it carries the same @spec as reserve() and the rest of the store. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660446 Check: bash scripts/run-hydra-gates.sh --scope-to-diff --base b9e5d8df -> re-run after this commit (see hand-back). Co-Authored-By: Claude Opus 5.5 --- lib/Service/Form/FormIdempotencyStore.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Service/Form/FormIdempotencyStore.php b/lib/Service/Form/FormIdempotencyStore.php index 0e4b6d2cfa..987a68ab92 100644 --- a/lib/Service/Form/FormIdempotencyStore.php +++ b/lib/Service/Form/FormIdempotencyStore.php @@ -129,6 +129,8 @@ public function reserve(string $scope, string $key): void { * @param string $key The Idempotency-Key. * * @return void + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference */ public function release(string $scope, string $key): void { try { From 50bee16d8fbc22d659ada5256f79bd94ef4f603d Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:46:17 +0200 Subject: [PATCH 25/37] =?UTF-8?q?fix(review):=20#4580=20eng-f10=20?= =?UTF-8?q?=E2=80=94=20pre-push:=20do=20not=20rethrow=20from=20the=20liste?= =?UTF-8?q?ner=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CasePlanStateMachine::flushEvents() catches Throwable around every dispatchTyped after the transition is committed, so the rethrow changed only a log line. It is reverted: arm() returns false again, and the error-level log now says the item is active WITHOUT its statutory expiry timer, naming the plan item, the case object and the legal effect. No schema field is added; a visible marker on the item is for the author. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660540 Check: phpunit tests/Unit/Service/Case OK 108 tests; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Case/CasePlanItemTimers.php | 25 +++++++++++-------- .../Service/Case/CasePlanItemTimersTest.php | 25 ++++++++++++------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/lib/Service/Case/CasePlanItemTimers.php b/lib/Service/Case/CasePlanItemTimers.php index 56cfc12947..0542899c0f 100644 --- a/lib/Service/Case/CasePlanItemTimers.php +++ b/lib/Service/Case/CasePlanItemTimers.php @@ -85,8 +85,6 @@ public function __construct( * * @return int How many timers were armed. * - * @throws Throwable When the timer of a statutory (wettelijk) expiry could not be armed; any other refusal is logged. - * * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ public function onEntered(CaseItem $item, ?string $actor, ?DateTimeInterface $now = null): int { @@ -173,8 +171,6 @@ public function itemToTerminate(FlowTimerFiredEvent $event): ?string { * * @return boolean True when armed. * - * @throws Throwable When the timer of a statutory (wettelijk) deadline could not be armed. - * * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock */ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadline, ?string $actor, DateTimeImmutable $now): bool { @@ -220,16 +216,23 @@ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadlin return true; } catch (Throwable $failure) { if ($config['legalEffect'] === FlowTimer::LEGAL_WETTELIJK) { - // A statutory expiry that is not armed is a term that never - // runs: the item would stay active past a legal deadline with - // nothing to end it. That is not a warning to log and move on - // from; it is raised to the caller of the transition. + // The transition is already committed and the caller swallows an + // exception raised from here (CasePlanStateMachine::flushEvents()), + // so this cannot fail it. It can only say loudly that the item is + // active without the timer that enforces its statutory expiry. $this->logger->error( - '[CasePlanItemTimers] The timer of a statutory plan-item deadline could not be armed: ' . $failure->getMessage(), - ['item' => $item->getUuid(), 'purpose' => $purpose, 'exception' => $failure] + sprintf( + '[CasePlanItemTimers] Plan item %s of case object %s is active WITHOUT its statutory (%s) %s timer: the term will not run and nothing will end the item. %s', + (string)$item->getItemKey(), + (string)$item->getObjectUuid(), + FlowTimer::LEGAL_WETTELIJK, + $purpose, + $failure->getMessage() + ), + ['item' => $item->getUuid(), 'case' => $item->getObjectUuid(), 'legalEffect' => FlowTimer::LEGAL_WETTELIJK, 'purpose' => $purpose, 'exception' => $failure] ); - throw $failure; + return false; } $this->logger->warning( diff --git a/tests/Unit/Service/Case/CasePlanItemTimersTest.php b/tests/Unit/Service/Case/CasePlanItemTimersTest.php index 40084031d0..9e43b680c2 100644 --- a/tests/Unit/Service/Case/CasePlanItemTimersTest.php +++ b/tests/Unit/Service/Case/CasePlanItemTimersTest.php @@ -163,25 +163,32 @@ static function (array $config) use (&$configs): FlowTimer { }//end testEdgesNeverBlockTheTransition() /** - * A refused timer of a statutory expiry is raised, not logged away; a service-norm or advisory one stays a warning. + * A refused timer of a statutory expiry is logged at error level naming case, item and legal effect; the transition stands. * * @return void */ - public function testARefusedStatutoryTimerIsRaised(): void { + public function testARefusedStatutoryTimerIsLoggedLoudly(): void { $now = new DateTimeImmutable('2026-10-10T10:00:00+00:00'); $statutory = CaseFixtures::row(id: 9, key: 'stat', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); $statutory->setExpiresAt(new DateTime('2026-10-20T10:00:00+00:00')); $statutory->setPlanSettings(['statutory' => ['stat']]); - $norm = CaseFixtures::row(id: 10, key: 'norm', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); - $norm->setExpiresAt(new DateTime('2026-10-20T10:00:00+00:00')); $this->timers->method('arm')->willThrowException(new RuntimeException('calendar unknown')); + $errors = []; + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $logger->method('error')->willReturnCallback( + static function (string $message, array $context = []) use (&$errors): void { + $errors[] = [$message, $context]; + } + ); + $service = new CasePlanItemTimers(timers: $this->timers, logger: $logger); - $this->assertSame(0, $this->service()->onEntered(item: $norm, actor: null, now: $now), 'a service norm stays a warning'); + $this->assertSame(0, $service->onEntered(item: $statutory, actor: null, now: $now)); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('calendar unknown'); - $this->service()->onEntered(item: $statutory, actor: null, now: $now); - }//end testARefusedStatutoryTimerIsRaised() + $this->assertCount(1, $errors); + $this->assertStringContainsString('WITHOUT its statutory', $errors[0][0]); + $this->assertStringContainsString('stat', $errors[0][0]); + $this->assertSame('wettelijk', $errors[0][1]['legalEffect']); + }//end testARefusedStatutoryTimerIsLoggedLoudly() /** * Reaching a terminal state cancels the item's open timers; a failure to From 2d0dbae3fb0340131d9a2b769603c16e4dde4c87 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:48:02 +0200 Subject: [PATCH 26/37] =?UTF-8?q?fix(review):=20#4580=20data-f1=20?= =?UTF-8?q?=E2=80=94=20pre-push:=20bulk=20and=20single=20delete=20keep=20a?= =?UTF-8?q?nother=20user's=20draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PermissionHandler::filterUuidsForPermissions() now asks DraftStatusPolicy::keptFrom() before the delete permission, so another user's draft is left out of a bulk delete like an object that does not exist. ObjectService::deleteObject() asks it too (RBAC on) after the object is found and before the handler runs, answering DoesNotExistException, as the save path does; the read lookup already hid it through the RBAC predicate, this is the explicit rule. Tests: bulk filter (fails at HEAD) and single delete. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660284 Check: phpunit PermissionHandlerBulkDeleteLookupTest, ObjectServiceBulkDeleteTest, ObjectServiceDraftDeleteTest and tests/Unit/Service/Object OK; phpcs on both lib files clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Object/PermissionHandler.php | 7 +++ lib/Service/ObjectService.php | 11 ++++ .../PermissionHandlerBulkDeleteLookupTest.php | 50 +++++++++++++++++++ .../Service/ObjectServiceDraftDeleteTest.php | 20 ++++++++ 4 files changed, 88 insertions(+) diff --git a/lib/Service/Object/PermissionHandler.php b/lib/Service/Object/PermissionHandler.php index afff4b12e4..9bab97ed3e 100644 --- a/lib/Service/Object/PermissionHandler.php +++ b/lib/Service/Object/PermissionHandler.php @@ -1862,6 +1862,13 @@ public function filterUuidsForPermissions(array $uuids, bool $_rbac, bool $_mult try { $schema = $this->schemaMapper->find($objectSchema); + // Another user's draft is kept from this caller as on every + // other path: it is left out like an object that does not + // exist, so the delete does not announce it. + if ((new DraftStatusPolicy())->keptFrom(object: $object, schema: $schema, userId: $userId) === true) { + continue; + } + // Property-level RBAC (per-property authorization + writeOnly // stripping) lives in PropertyRbacHandler on the read path; this // loop only decides object-level visibility. diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 59780d6005..1a7c774005 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -3151,6 +3151,7 @@ public function deleteObject( $scopedSchema = $this->currentSchema; } + $objectToDelete = null; try { // With the caller's flags. The delete handler honours them, so a // lookup that ignored them applied the session's RBAC and tenant @@ -3212,6 +3213,16 @@ public function deleteObject( } }//end try + // Somebody else's draft is not the caller's to delete, whatever the + // read lookup above let through. Answered as not found, like the + // save path (refuseSomebodyElsesDraft), so it is not announced. + if ($_rbac === true && $objectToDelete !== null && $this->currentSchema !== null) { + $deleter = ($actingUserId ?? $this->userSession->getUser()?->getUID()); + if ((new DraftStatusPolicy())->keptFrom(object: $objectToDelete, schema: $this->currentSchema, userId: $deleter) === true) { + throw new \OCP\AppFramework\Db\DoesNotExistException('Object not found'); + } + } + return $this->deleteHandler->deleteObject( register: $this->currentRegister, schema: $this->currentSchema, diff --git a/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php b/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php index a5cd550a0d..b469da4978 100644 --- a/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php +++ b/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php @@ -167,4 +167,54 @@ public function testUnresolvableUuidIsDropped(): void { $this->assertSame(['uuid-alpha'], $filtered); }//end testUnresolvableUuidIsDropped() + + /** + * A bulk delete leaves somebody else's draft out, like an object that does not exist, and keeps the caller's own. + * + * @return void + */ + public function testSomebodyElsesDraftIsFilteredOutOfABulkDelete(): void { + $bobsDraft = $this->entity('draft-bob'); + $bobsDraft->setStatus('draft'); + $bobsDraft->setOwner('bob'); + $bobsDraft->setSchema('5'); + $alicesDraft = $this->entity('draft-alice'); + $alicesDraft->setStatus('draft'); + $alicesDraft->setOwner('alice'); + $alicesDraft->setSchema('5'); + $record = $this->entity('record'); + $record->setStatus('active'); + $record->setOwner('alice'); + $record->setSchema('5'); + + $mapper = $this->createMock(MagicMapper::class); + $mapper->method('findMultipleAcrossAllMagicTables')->willReturn([$bobsDraft, $alicesDraft, $record]); + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturn(new \OCA\OpenRegister\Db\Schema()); + $user = $this->createMock(\OCP\IUser::class); + $user->method('getUID')->willReturn('bob'); + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willThrowException(new \RuntimeException('no organisation service')); + $handler = new PermissionHandler( + userSession: $session, + userManager: $this->createMock(IUserManager::class), + groupManager: $this->createMock(IGroupManager::class), + schemaMapper: $schemas, + objectEntityMapper: $mapper, + conditionMatcher: $this->createMock(ConditionMatcher::class), + appConfig: $this->createMock(IAppConfig::class), + logger: $this->createMock(LoggerInterface::class), + container: $container, + ); + + // The schema's own delete rule is not what this asks (it would need a real user and groups); + // run inside the system context so hasPermission() says yes and only the draft rule decides. + $filtered = \OCA\OpenRegister\Service\SystemOperationContext::run( + static fn (): array => $handler->filterUuidsForPermissions(uuids: ['draft-bob', 'draft-alice', 'record'], _rbac: true, _multitenancy: false) + ); + + $this->assertSame(['draft-bob', 'record'], $filtered, "alice's draft is left out; bob's own draft and an ordinary record stay"); + }//end testSomebodyElsesDraftIsFilteredOutOfABulkDelete() }//end class diff --git a/tests/Unit/Service/ObjectServiceDraftDeleteTest.php b/tests/Unit/Service/ObjectServiceDraftDeleteTest.php index 4f79a3350b..8c15fc1a89 100644 --- a/tests/Unit/Service/ObjectServiceDraftDeleteTest.php +++ b/tests/Unit/Service/ObjectServiceDraftDeleteTest.php @@ -337,6 +337,26 @@ public function testADraftIsDeletedForGoodOnAnySchema(): void { $this->assertSame([true], $asked); }//end testADraftIsDeletedForGoodOnAnySchema() + /** + * Somebody else's draft is not deleted through the single-object path, and the handler is never asked. + * + * @return void + */ + public function testSomebodyElsesDraftIsNotDeleted(): void { + [$register, $schema] = $this->scope(archival: false); + $this->stored(status: 'draft'); + $asked = []; + $this->recordHandler(asked: $asked); + + $this->expectException(\OCP\AppFramework\Db\DoesNotExistException::class); + try { + // The stored draft belongs to 'agent'; the caller (no session user) is not that owner. + $this->service->deleteObject(uuid: 'draft-1', register: $register, schema: $schema, _rbac: true, _multitenancy: false); + } finally { + $this->assertSame([], $asked); + } + }//end testSomebodyElsesDraftIsNotDeleted() + /** * Control: an ordinary object's delete stays the soft delete it was. * From c5354845b3fba42eb7c16e1393632adc0c4928a5 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:48:19 +0200 Subject: [PATCH 27/37] =?UTF-8?q?fix(review):=20#4580=20data-f1=20?= =?UTF-8?q?=E2=80=94=20pre-push:=20draft=20guard=20before=20the=20appendOn?= =?UTF-8?q?ly=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft guard (Step 2b) now runs above the appendOnly gate in applyBulkSafeguards(), so a non-owner of a draft on an appendOnly schema gets the same 'Permission denied' as everywhere else instead of an appendOnly message that confirms the uuid names an existing row. New test. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660284 Check: phpunit Wave12BulkSafeguardsTest + BulkSafeguardSchemaResolutionTest OK; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Object/SaveObjects.php | 26 ++++++++++--------- .../Object/Wave12BulkSafeguardsTest.php | 24 +++++++++++++++++ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/lib/Service/Object/SaveObjects.php b/lib/Service/Object/SaveObjects.php index 2c582d3aae..826aaf54f8 100644 --- a/lib/Service/Object/SaveObjects.php +++ b/lib/Service/Object/SaveObjects.php @@ -802,18 +802,9 @@ private function applyBulkSafeguards( existingByUuid: $existingByUuid ); - // Step 4: appendOnly UPDATE → reject. - if ($action === 'update' && $rowSchema->isAppendOnly() === true) { - $this->recordSafeguardRejection( - object: $sanitised, - reason: 'Schema is appendOnly; UPDATE rejected for row UUID ' . ((string)($uuid ?? '?')), - result: $result, - index: $rowIndex - ); - continue; - } - - // Step 2b: a draft is kept to its owner. The existing row was found + // Step 2b: a draft is kept to its owner. Asked before the appendOnly + // gate, whose message would otherwise tell a non-owner that the uuid + // names an existing row. The existing row was found // past the read RBAC, so without this a user who cannot read another // user's draft could overwrite it by sending its uuid. The answer is // the one a refused permission gives, so the draft's existence stays @@ -835,6 +826,17 @@ private function applyBulkSafeguards( } } + // Step 4: appendOnly UPDATE → reject. + if ($action === 'update' && $rowSchema->isAppendOnly() === true) { + $this->recordSafeguardRejection( + object: $sanitised, + reason: 'Schema is appendOnly; UPDATE rejected for row UUID ' . ((string)($uuid ?? '?')), + result: $result, + index: $rowIndex + ); + continue; + } + // Step 3: PermissionHandler check (per row). if ($effectiveRbac === true) { $hasPermission = $this->permissionHandler->hasPermission( diff --git a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php index 5f274f4d2a..225cf031e0 100644 --- a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php +++ b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php @@ -354,6 +354,30 @@ public function testBulkSaveOverSomebodyElsesDraftIsRejected(): void { $this->assertStringContainsString('Permission denied', $result['invalid'][0]['error']); }//end testBulkSaveOverSomebodyElsesDraftIsRejected() + /** + * On an appendOnly schema the draft rule answers first: a non-owner is not told by the appendOnly message that the uuid exists. + */ + public function testAppendOnlyDoesNotRevealSomebodyElsesDraft(): void { + $this->mockUser('bob', isAdmin: false); + $schema = $this->newSchema(1, null, appendOnly: true); + $register = $this->newRegister(10); + $this->permissionHandler->method('hasPermission')->willReturn(true); + $this->storeDraft(owner: 'alice'); + + $result = $this->initResult(1); + $this->callApplyBulkSafeguards( + objects: [['@self' => ['uuid' => '22222222-2222-2222-2222-222222222222'], 'title' => 'probe']], + register: $register, + schema: $schema, + _rbac: true, + _validation: false, + result: $result + ); + + $this->assertStringContainsString('Permission denied', $result['invalid'][0]['error']); + $this->assertStringNotContainsString('appendOnly', $result['invalid'][0]['error']); + }//end testAppendOnlyDoesNotRevealSomebodyElsesDraft() + /** * The owner's own bulk save of their draft still goes through. */ From fb4be1d92748ad90f44709dbca9d7eaf1722869d Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:49:41 +0200 Subject: [PATCH 28/37] =?UTF-8?q?fix(review):=20#4580=20plat-f3=20?= =?UTF-8?q?=E2=80=94=20pre-push:=20say=20what=20an=20oversized=20entry=20l?= =?UTF-8?q?oses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping oldData for an entry that alone exceeds the job limit keeps the entry but skips the conditioned updated rules and the calculatedChange rules for that object (they fail closed without the previous data). The warning now says so, the docblock and comments no longer claim the notification is still sent, and the proposal, spec requirement and scenario of deferred-listener-batches-stay-under-the-job-limit are corrected; the test was renamed accordingly and asserts the log text. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660676 Check: phpunit tests/Unit/Service/Deferral OK; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Deferral/ListenerDeferralService.php | 16 +++++++++------- .../proposal.md | 2 +- .../specs/event-driven-architecture/spec.md | 8 ++++---- .../DeferredChunksFitTheJobLimitTest.php | 5 +++-- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/Service/Deferral/ListenerDeferralService.php b/lib/Service/Deferral/ListenerDeferralService.php index a76845dd9c..ea96b09ba8 100644 --- a/lib/Service/Deferral/ListenerDeferralService.php +++ b/lib/Service/Deferral/ListenerDeferralService.php @@ -308,9 +308,11 @@ public function flushAll(): void { * entry in it was dropped with one log line (zuiddrecht live test, 14 * October 2026: 11 chunks of 25 in one cron run). Now the entries are * spread over as many jobs as the running server's limit needs. An entry - * that alone exceeds it goes without its `oldData` (the notification is - * still sent, and the log names the reduction); only an entry that is - * still too large after that is dropped, and it is named in the log. + * that alone exceeds it goes without its `oldData`, which keeps the entry + * but means the conditioned `updated` rules and the `calculatedChange` + * rules, which fail closed without the previous data, are skipped for that + * object (the log names it); only an entry that is still too large after + * that is dropped, and it is named in the log. * * @param string $jobClass FQCN of the job to enqueue. * @@ -353,15 +355,15 @@ private function chunksUnderLimit(string $jobClass, array $entries): array { foreach ($entries as $entry) { $length = (strlen((string)json_encode($entry)) + 1); if ($length > $budget && array_key_exists('oldData', $entry) === true) { - // The previous data is the bulk of a large update. Without it the - // 'updated' notification is still sent; only the comparison of old - // against new is skipped. + // The previous data is the bulk of a large update. Losing it beats + // losing the entry, but it is not free: rules that compare old + // against new fail closed without it and are skipped. $reduced = $entry; unset($reduced['oldData']); $reducedLength = (strlen((string)json_encode($reduced)) + 1); if ($reducedLength <= $budget) { $this->logger->warning( - message: '[ListenerDeferralService] A deferred listener entry was too large for a background job; it was enqueued without its previous data', + message: '[ListenerDeferralService] A deferred listener entry was too large for a background job; it was enqueued without its previous data; conditioned updated rules and calculatedChange rules are skipped for this object', context: [ 'file' => __FILE__, 'line' => __LINE__, diff --git a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md index 292ccb060f..9eced6bf23 100644 --- a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md +++ b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md @@ -17,7 +17,7 @@ Zuiddrecht live test, 14 October 2026: one cron run that swept hundreds of dossi ## What changes - `ListenerDeferralService::flushBuffer()` splits the buffer into consecutive chunks whose JSON argument stays within the running server's job argument limit (`ListenerDeferralService::getArgumentLimit()`: `JobList::MAX_ARGUMENT_JSON_LENGTH` where Nextcloud defines it, 32,000 from 34, else the 4,000 of Nextcloud 32 and 33), measured the way Nextcloud's job list measures it, and enqueues each. -- An entry larger than the limit is enqueued without its `oldData` (the update notification is still sent) and the reduction is logged at warning level. +- An entry larger than the limit is enqueued without its `oldData` (the entry is kept; conditioned `updated` rules and `calculatedChange` rules, which fail closed without the previous data, are skipped for that object) and the reduction is logged at warning level. - An entry still too large after that is logged at error level with its job class and uuid, and the rest of its chunk still goes. ## Screens diff --git a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md index b28f54cbbf..4b8a3a96da 100644 --- a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md +++ b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md @@ -4,7 +4,7 @@ ### Requirement: A deferred listener chunk fits the job argument limit -The listener deferral service SHALL enqueue a buffered chunk as one or more background jobs whose JSON-encoded argument each stays within the running Nextcloud's job argument limit (4,000 characters on Nextcloud 32 and 33, 32,000 from 34; read from the server when it names the limit, 4,000 otherwise), keeping every entry once and in order. An entry that alone exceeds the limit SHALL be enqueued without its `oldData` and the reduction logged; an entry that still exceeds it SHALL be left out and logged with its job class and uuid. The other entries of its chunk SHALL still be enqueued. +The listener deferral service SHALL enqueue a buffered chunk as one or more background jobs whose JSON-encoded argument each stays within the running Nextcloud's job argument limit (4,000 characters on Nextcloud 32 and 33, 32,000 from 34; read from the server when it names the limit, 4,000 otherwise), keeping every entry once and in order. An entry that alone exceeds the limit SHALL be enqueued without its `oldData` and the reduction logged, naming that conditioned `updated` rules and `calculatedChange` rules are skipped for that object (they fail closed without the previous data); an entry that still exceeds it SHALL be left out and logged with its job class and uuid. The other entries of its chunk SHALL still be enqueued. #### Scenario: Twenty-five updates of large cases - GIVEN a schema that declares update notifications and 25 updates of cases of about 2,000 characters each in one request @@ -12,11 +12,11 @@ The listener deferral service SHALL enqueue a buffered chunk as one or more back - THEN every update reaches the job list once, in order, spread over more than one job, and no job argument is longer than the active limit (checked at 4,000 and at 32,000) - @e2e exclude pinned through `AnnotationNotificationListener` by `DeferredChunksFitTheJobLimitTest::testAChunkOfLargeUpdatesIsSpreadOverJobsTheListTakes` -#### Scenario: An entry too large for the active limit keeps its notification +#### Scenario: An entry too large for the active limit is kept without its previous data - GIVEN an update whose previous data alone makes its entry longer than the active limit - WHEN the buffer is flushed -- THEN the entry is enqueued without `oldData`, the log names its uuid, and the notification is still sent -- @e2e exclude pinned by `DeferredChunksFitTheJobLimitTest::testAnOversizedUpdateLosesItsOldDataNotItsNotification` +- THEN the entry is enqueued without `oldData`, and the log names its uuid and says that conditioned `updated` rules and `calculatedChange` rules are skipped for this object +- @e2e exclude pinned by `DeferredChunksFitTheJobLimitTest::testAnOversizedUpdateIsKeptWithoutItsOldData` #### Scenario: One entry too large for any job - GIVEN three updates of which the middle one is larger than a job may carry diff --git a/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php b/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php index ebbcd3214a..43bc59d13d 100644 --- a/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php +++ b/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php @@ -257,7 +257,7 @@ public function testAChunkOfLargeUpdatesIsSpreadOverJobsTheListTakes(int $limit, * * @spec openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md#requirement-a-deferred-listener-chunk-fits-the-job-argument-limit */ - public function testAnOversizedUpdateLosesItsOldDataNotItsNotification(): void { + public function testAnOversizedUpdateIsKeptWithoutItsOldData(): void { $deferral = $this->deferral(limit: ListenerDeferralService::ARGUMENT_LENGTH_NC32); $listener = $this->listener(deferral: $deferral); $listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: 'small-1', length: 100), $this->caseOf(uuid: 'small-1', length: 100))); @@ -270,12 +270,13 @@ public function testAnOversizedUpdateLosesItsOldDataNotItsNotification(): void { self::assertSame(['small-1', 'big', 'small-2'], $this->enqueuedUuids()); self::assertCount(1, $this->warnings); self::assertSame('big', $this->warnings[0][1]['uuid']); + self::assertStringContainsString('calculatedChange rules are skipped', $this->warnings[0][0]); foreach ($this->enqueued as $argument) { foreach ($argument['entries'] as $entry) { self::assertSame($entry['uuid'] !== 'big', array_key_exists('oldData', $entry) && $entry['oldData'] !== null); } } - }//end testAnOversizedUpdateLosesItsOldDataNotItsNotification() + }//end testAnOversizedUpdateIsKeptWithoutItsOldData() /** * One entry too large for any job, whatever it carries, is named in the From 59f9acaccba57565a3ca2ac61b68033775b364d4 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:49:41 +0200 Subject: [PATCH 29/37] =?UTF-8?q?fix(review):=20#4580=20plat-f3=20?= =?UTF-8?q?=E2=80=94=20pre-push:=20contain=20a=20refused=20webhook=20job?= =?UTF-8?q?=20argument?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebhookService::dispatchEvent() enqueues WebhookDeliveryJob with the whole event payload as the job argument, the same 4000 (NC 32 and 33) or 32000 (34 and later) limit. A refusal from IJobList::add() was uncaught: it aborted the webhooks after it in the loop and surfaced as an exception in the event listener, where it is only a log line. Each add is now guarded: the refusal is logged at error level with the webhook id, event, payload length and message, and the loop goes on. That delivery is still lost (no redesign of webhook delivery; trimming or a payload reference is the author's call). Test: a refused first webhook does not stop the second. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660676 Check: phpunit WebhookServiceTest, WebhookAsyncDispatchTest and WebhookEventListenerTest OK; phpcs on the lib file clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/WebhookService.php | 38 +++++++++++++++++------ tests/Unit/Service/WebhookServiceTest.php | 20 ++++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/lib/Service/WebhookService.php b/lib/Service/WebhookService.php index 83848c5859..fc94eb7c0f 100644 --- a/lib/Service/WebhookService.php +++ b/lib/Service/WebhookService.php @@ -48,6 +48,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; use RuntimeException; +use Throwable; /** * WebhookService handles webhook delivery and request interception @@ -677,15 +678,34 @@ public function dispatchEvent(Event $_event, string $eventName, array $payload): // latency a function of third-party uptime (async-webhook-delivery / // ADR-009 Rule 5). WebhookDeliveryJob performs the same delivery + logging. foreach ($webhooks as $webhook) { - $this->jobList->add( - WebhookDeliveryJob::class, - [ - 'webhook_id' => $webhook->getId(), - 'event_name' => $eventName, - 'payload' => $payload, - 'attempt' => 1, - ] - ); + try { + $this->jobList->add( + WebhookDeliveryJob::class, + [ + 'webhook_id' => $webhook->getId(), + 'event_name' => $eventName, + 'payload' => $payload, + 'attempt' => 1, + ] + ); + } catch (Throwable $e) { + // Nextcloud refuses a job argument over 4000 characters (32 and 33) + // or 32000 (34 and later), and the payload is the whole event. The + // refusal must not abort the webhooks after this one, nor surface + // as an exception in the listener (where it is only a log line); + // this delivery is lost, and the log says which one and why. + $this->logger->error( + message: '[WebhookService] A webhook delivery could not be enqueued and was not sent', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'webhook_id' => $webhook->getId(), + 'event' => $eventName, + 'payload_length' => strlen((string)json_encode($payload)), + 'error' => $e->getMessage(), + ] + ); + } } }//end dispatchEvent() diff --git a/tests/Unit/Service/WebhookServiceTest.php b/tests/Unit/Service/WebhookServiceTest.php index 1950ec53bf..59da0fff22 100644 --- a/tests/Unit/Service/WebhookServiceTest.php +++ b/tests/Unit/Service/WebhookServiceTest.php @@ -676,6 +676,26 @@ public function testDispatchEventEnqueuesDeliveryJobPerWebhookAndDoesNotDeliverS $this->assertSame(2, $enqueued[1][1]['webhook_id']); } + public function testARefusedJobArgumentDoesNotStopTheOtherWebhooks(): void { + $event = $this->createMock(\OCP\EventDispatcher\Event::class); + $this->webhookMapper->method('findForEvent')->willReturn([$this->createTestWebhook(id: 1), $this->createTestWebhook(id: 2)]); + + $enqueued = []; + $this->jobList->method('add')->willReturnCallback(function (string $job, array $arg) use (&$enqueued) { + if ($arg['webhook_id'] === 1) { + throw new \InvalidArgumentException("Background job arguments can't exceed 4000 characters (json encoded)"); + } + + $enqueued[] = $arg['webhook_id']; + }); + $this->logger->expects($this->atLeastOnce())->method('error') + ->with($this->stringContains('could not be enqueued'), $this->callback(static fn (array $context): bool => $context['webhook_id'] === 1)); + + $this->service->dispatchEvent($event, 'SomeEvent', ['test' => str_repeat('x', 5000)]); + + $this->assertSame([2], $enqueued, 'the refusal is contained to the one delivery'); + } + // ─── deliverWebhook tests ──────────────────────────────────────── public function testDeliverWebhookReturnsFalseWhenDisabled(): void { From 6703ff76a5bddc12dcfe44d49745a58695be9602 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:49:57 +0200 Subject: [PATCH 30/37] =?UTF-8?q?fix(review):=20#4580=20eng-f3=20=E2=80=94?= =?UTF-8?q?=20pre-push:=20scenario=20and=20docs=20follow=20the=20new=20key?= =?UTF-8?q?=20behaviour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idempotency scenario in form-destination-validator now says same subject and same payload; two new scenarios state the 409 for another subject or payload and for a parallel retry. docs/features/form-destination.md states that the parallel-retry protection relies on Nextcloud file locking (filelocking.enabled); without it only the sequential repeat is answered from the stored answer. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660446 Check: documentation only; no test run. Co-Authored-By: Claude Opus 5.5 --- docs/features/form-destination.md | 5 ++++- .../specs/form-destination/spec.md | 14 +++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/features/form-destination.md b/docs/features/form-destination.md index 62cc522fe8..bf9242ea69 100644 --- a/docs/features/form-destination.md +++ b/docs/features/form-destination.md @@ -87,7 +87,10 @@ OpenRegister object (`status: published`, `destination` or `writes`, A retried `Idempotency-Key` returns the first answer for 24 hours, to the same person with the same payload only; the key is reserved while its submit runs, so -a parallel retry answers 409 instead of creating a second object. When one +a parallel retry answers 409 instead of creating a second object. That +parallel-retry protection relies on Nextcloud's file locking (`filelocking.enabled`, +on by default); with file locking switched off the key is not reserved and only the +sequential repeat is answered from the stored answer. When one of several writes is refused, the earlier writes of that submit are deleted before the answer, and the deletion is logged. diff --git a/openspec/changes/form-destination-validator/specs/form-destination/spec.md b/openspec/changes/form-destination-validator/specs/form-destination/spec.md index af1e91a99f..8de3bdc0ff 100644 --- a/openspec/changes/form-destination-validator/specs/form-destination/spec.md +++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md @@ -47,9 +47,21 @@ #### Scenario: A repeated idempotency key repeats the answer - **GIVEN** a submit with key `k1` that created object 7 -- **WHEN** the same key is submitted again within 24 hours +- **WHEN** the same key is submitted again within 24 hours by the same subject with the same payload - **THEN** the response repeats object 7's reference and no object is created +#### Scenario: A repeated key from somebody else, or with another payload, is refused + +- **GIVEN** a submit with key `k1` that created object 7 +- **WHEN** another subject, or the same subject with another payload, submits key `k1` within 24 hours +- **THEN** the response is 409, carries nothing of object 7's answer, and no object is created + +#### Scenario: A parallel retry with the same key is refused + +- **GIVEN** a submit with key `k1` that is still being written +- **WHEN** the same key arrives again before it finishes +- **THEN** the second request is answered 409 and only one object is created + ### Requirement: A submit writing several objects MUST commit all or none The service SHALL validate every write before the first. When a write is refused after earlier writes succeeded, it SHALL delete those earlier writes before responding, and SHALL audit both the create and the delete. From 9420851e65f035512b0342c3f3c700f6b3e44f16 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:49:57 +0200 Subject: [PATCH 31/37] =?UTF-8?q?fix(review):=20#4580=20surf-f4=20?= =?UTF-8?q?=E2=80=94=20pre-push:=20federated-configuration=20doc=20says=20?= =?UTF-8?q?user=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/federated-configuration.md still sent readers to the Configuration store pane in personal settings; it says user settings now. Other languages' l10n entries stay for Transifex. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660858 Check: documentation only; git grep -i 'personal settings' -- docs finds nothing. Co-Authored-By: Claude Opus 5.5 --- docs/federated-configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/federated-configuration.md b/docs/federated-configuration.md index 504b3240ef..8a72f20c26 100644 --- a/docs/federated-configuration.md +++ b/docs/federated-configuration.md @@ -26,7 +26,7 @@ rules. ## Choose which GitHub credential the store uses The store never assumes your only GitHub key is the one you meant to use. In the -app's **Configuration store** settings pane (personal settings), pick which of +app's **Configuration store** settings pane (user settings), pick which of your GitHub credentials the store should publish and browse with. The choice is saved as a per-user preference; the credential itself is custodied by the broker. From 902c28b14cbf57fc3850857f0f686884a18476df Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:52:23 +0200 Subject: [PATCH 32/37] =?UTF-8?q?fix(review):=20#4580=20surf-f1=20?= =?UTF-8?q?=E2=80=94=20pre-push:=20phpcs=20inline=20if=20in=20logsForReade?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the inline ternary phpcs refuses in ObjectsController::logsForReader() with an if; no behaviour change. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660832 Check: phpunit ObjectsControllerTest + ObjectsControllerLogsRedactionTest OK; phpcs on all touched lib files now clean. Co-Authored-By: Claude Opus 5.5 --- lib/Controller/ObjectsController.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php index 6b4678228a..9a7b8b425a 100644 --- a/lib/Controller/ObjectsController.php +++ b/lib/Controller/ObjectsController.php @@ -4894,7 +4894,10 @@ public function logs(string $id, string $register, string $schema, ObjectService */ private function logsForReader(string $register, string $schema, string $id): JSONResponse { $config = $this->getConfig(_register: $register, _schema: $schema); - $search = $config['_search']; + $search = null; + if ($config['_search'] !== null) { + $search = (string)$config['_search']; + } try { $page = $this->container->get(ObjectHistoryForReaders::class)->page( @@ -4907,7 +4910,7 @@ private function logsForReader(string $register, string $schema, string $id): JS 'page' => $config['page'], 'filters' => [], 'sort' => [], - 'search' => ($search === null ? null : (string)$search), + 'search' => $search, ] ); } catch (PurposeRefusedException $refusal) { From d942f683a97004d052053da58dc0bebca91e0db2 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 18:56:45 +0200 Subject: [PATCH 33/37] =?UTF-8?q?fix(review):=20#4580=20data-f1=20?= =?UTF-8?q?=E2=80=94=20pre-push:=20admins=20pass=20the=20draft=20guards=20?= =?UTF-8?q?like=20the=20read=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three draft guards (bulk delete filter in PermissionHandler, ObjectService::deleteObject, SaveObjects bulk-save safeguard) no longer refuse an administrator: an administrator reads every draft (MagicRbacHandler bypass) and the single save already lets one through, so they use the same group check (the 'admin' group id) the handlers already use. A non-owner non-admin is still refused. Tests: admin passes the bulk delete filter, deleteObject and the bulk save; a non-owner non-admin is still kept; and the owner deletes their own draft through ObjectService::deleteObject. Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660284 Check: phpunit tests/Unit/Service/Object + ObjectServiceDraftDeleteTest + ObjectServiceBulkDeleteTest + BulkSafeguardSchemaResolutionTest OK 2747 tests; phpcs on the three lib files clean. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Object/PermissionHandler.php | 7 +- lib/Service/Object/SaveObjects.php | 3 +- lib/Service/ObjectService.php | 9 ++- .../PermissionHandlerBulkDeleteLookupTest.php | 33 ++++++-- .../Object/Wave12BulkSafeguardsTest.php | 24 ++++++ .../Service/ObjectServiceDraftDeleteTest.php | 78 ++++++++++++++++++- 6 files changed, 142 insertions(+), 12 deletions(-) diff --git a/lib/Service/Object/PermissionHandler.php b/lib/Service/Object/PermissionHandler.php index 9bab97ed3e..1018f2b9de 100644 --- a/lib/Service/Object/PermissionHandler.php +++ b/lib/Service/Object/PermissionHandler.php @@ -1829,6 +1829,11 @@ public function filterUuidsForPermissions(array $uuids, bool $_rbac, bool $_mult $activeOrganisation = $this->getActiveOrganisationForContext(); + // An administrator reads every draft, so is not kept from one here + // (the same check hasPermission() and the read predicate make). + $callerIsAdmin = ($currentUser !== null + && in_array('admin', $this->groupManager->getUserGroupIds($currentUser), true) === true); + // Get objects for permission checking. // // This must be the CROSS-TABLE lookup. `MagicMapper::findAll()` needs a @@ -1865,7 +1870,7 @@ public function filterUuidsForPermissions(array $uuids, bool $_rbac, bool $_mult // Another user's draft is kept from this caller as on every // other path: it is left out like an object that does not // exist, so the delete does not announce it. - if ((new DraftStatusPolicy())->keptFrom(object: $object, schema: $schema, userId: $userId) === true) { + if ($callerIsAdmin === false && (new DraftStatusPolicy())->keptFrom(object: $object, schema: $schema, userId: $userId) === true) { continue; } diff --git a/lib/Service/Object/SaveObjects.php b/lib/Service/Object/SaveObjects.php index 826aaf54f8..b66c3acb98 100644 --- a/lib/Service/Object/SaveObjects.php +++ b/lib/Service/Object/SaveObjects.php @@ -809,7 +809,8 @@ private function applyBulkSafeguards( // user's draft could overwrite it by sending its uuid. The answer is // the one a refused permission gives, so the draft's existence stays // unannounced (same rule as ObjectService::refuseSomebodyElsesDraft). - if ($effectiveRbac === true && $existingEntity !== null) { + // An administrator reads every draft, so is not kept from one. + if ($effectiveRbac === true && $isAdmin === false && $existingEntity !== null) { $draftKept = (new DraftStatusPolicy())->keptFrom( object: $existingEntity, schema: $rowSchema, diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 1a7c774005..b09fe96135 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -3217,8 +3217,13 @@ public function deleteObject( // read lookup above let through. Answered as not found, like the // save path (refuseSomebodyElsesDraft), so it is not announced. if ($_rbac === true && $objectToDelete !== null && $this->currentSchema !== null) { - $deleter = ($actingUserId ?? $this->userSession->getUser()?->getUID()); - if ((new DraftStatusPolicy())->keptFrom(object: $objectToDelete, schema: $this->currentSchema, userId: $deleter) === true) { + $deleterUser = ($currentUser ?? $this->userSession->getUser()); + $deleterIsAdmin = ($deleterUser !== null + && in_array('admin', $this->groupManager->getUserGroupIds($deleterUser), true) === true); + // An administrator reads every draft, so is not kept from one. + if ($deleterIsAdmin === false + && (new DraftStatusPolicy())->keptFrom(object: $objectToDelete, schema: $this->currentSchema, userId: $deleterUser?->getUID()) === true + ) { throw new \OCP\AppFramework\Db\DoesNotExistException('Object not found'); } } diff --git a/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php b/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php index b469da4978..7ba3e61bb4 100644 --- a/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php +++ b/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php @@ -174,6 +174,27 @@ public function testUnresolvableUuidIsDropped(): void { * @return void */ public function testSomebodyElsesDraftIsFilteredOutOfABulkDelete(): void { + $this->assertSame(['draft-bob', 'record'], $this->bulkDeleteFilterAs(uid: 'bob', groups: ['staff']), "alice's draft is left out; bob's own draft and an ordinary record stay"); + }//end testSomebodyElsesDraftIsFilteredOutOfABulkDelete() + + /** + * An administrator is not kept from another user's draft in a bulk delete, as on the read path. + * + * @return void + */ + public function testAnAdministratorKeepsEveryDraftInABulkDelete(): void { + $this->assertSame(['draft-bob', 'draft-alice', 'record'], $this->bulkDeleteFilterAs(uid: 'root', groups: ['admin'])); + }//end testAnAdministratorKeepsEveryDraftInABulkDelete() + + /** + * Run the bulk-delete filter over bob's draft, alice's draft and a record, as a user in the given groups. + * + * @param string $uid The caller. + * @param array $groups The caller's groups. + * + * @return array The uuids that survive. + */ + private function bulkDeleteFilterAs(string $uid, array $groups): array { $bobsDraft = $this->entity('draft-bob'); $bobsDraft->setStatus('draft'); $bobsDraft->setOwner('bob'); @@ -192,15 +213,17 @@ public function testSomebodyElsesDraftIsFilteredOutOfABulkDelete(): void { $schemas = $this->createMock(SchemaMapper::class); $schemas->method('find')->willReturn(new \OCA\OpenRegister\Db\Schema()); $user = $this->createMock(\OCP\IUser::class); - $user->method('getUID')->willReturn('bob'); + $user->method('getUID')->willReturn($uid); $session = $this->createMock(IUserSession::class); $session->method('getUser')->willReturn($user); + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('getUserGroupIds')->willReturn($groups); $container = $this->createMock(ContainerInterface::class); $container->method('get')->willThrowException(new \RuntimeException('no organisation service')); $handler = new PermissionHandler( userSession: $session, userManager: $this->createMock(IUserManager::class), - groupManager: $this->createMock(IGroupManager::class), + groupManager: $groupManager, schemaMapper: $schemas, objectEntityMapper: $mapper, conditionMatcher: $this->createMock(ConditionMatcher::class), @@ -211,10 +234,8 @@ public function testSomebodyElsesDraftIsFilteredOutOfABulkDelete(): void { // The schema's own delete rule is not what this asks (it would need a real user and groups); // run inside the system context so hasPermission() says yes and only the draft rule decides. - $filtered = \OCA\OpenRegister\Service\SystemOperationContext::run( + return \OCA\OpenRegister\Service\SystemOperationContext::run( static fn (): array => $handler->filterUuidsForPermissions(uuids: ['draft-bob', 'draft-alice', 'record'], _rbac: true, _multitenancy: false) ); - - $this->assertSame(['draft-bob', 'record'], $filtered, "alice's draft is left out; bob's own draft and an ordinary record stay"); - }//end testSomebodyElsesDraftIsFilteredOutOfABulkDelete() + }//end bulkDeleteFilterAs() }//end class diff --git a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php index 225cf031e0..f2c77ce40a 100644 --- a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php +++ b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php @@ -354,6 +354,30 @@ public function testBulkSaveOverSomebodyElsesDraftIsRejected(): void { $this->assertStringContainsString('Permission denied', $result['invalid'][0]['error']); }//end testBulkSaveOverSomebodyElsesDraftIsRejected() + /** + * An administrator is not kept from another user's draft by the bulk save, as on the read path. + */ + public function testAnAdministratorMayBulkSaveOverSomebodyElsesDraft(): void { + $this->mockUser('root', isAdmin: true); + $schema = $this->newSchema(1); + $register = $this->newRegister(10); + $this->permissionHandler->method('hasPermission')->willReturn(true); + $this->storeDraft(owner: 'alice'); + + $result = $this->initResult(1); + $passed = $this->callApplyBulkSafeguards( + objects: [['@self' => ['uuid' => '22222222-2222-2222-2222-222222222222'], 'title' => 'admin edit']], + register: $register, + schema: $schema, + _rbac: true, + _validation: false, + result: $result + ); + + $this->assertCount(1, $passed); + $this->assertSame(0, $result['statistics']['invalid']); + }//end testAnAdministratorMayBulkSaveOverSomebodyElsesDraft() + /** * On an appendOnly schema the draft rule answers first: a non-owner is not told by the appendOnly message that the uuid exists. */ diff --git a/tests/Unit/Service/ObjectServiceDraftDeleteTest.php b/tests/Unit/Service/ObjectServiceDraftDeleteTest.php index 8c15fc1a89..2da28606af 100644 --- a/tests/Unit/Service/ObjectServiceDraftDeleteTest.php +++ b/tests/Unit/Service/ObjectServiceDraftDeleteTest.php @@ -110,6 +110,10 @@ class ObjectServiceDraftDeleteTest extends TestCase { /** @var MockObject&DateTimeNormalizer */ private MockObject $dateTimeNormalizer; + private IUserSession&\PHPUnit\Framework\MockObject\MockObject $userSession; + + private IGroupManager&\PHPUnit\Framework\MockObject\MockObject $groupManager; + /** * Set up fresh service + mocks before each test. * @@ -118,6 +122,8 @@ class ObjectServiceDraftDeleteTest extends TestCase { protected function setUp(): void { parent::setUp(); + $this->userSession = $this->createMock(IUserSession::class); + $this->groupManager = $this->createMock(IGroupManager::class); $this->saveHandler = $this->createMock(SaveObject::class); $this->renderHandler = $this->createMock(RenderObject::class); $this->deleteHandler = $this->createMock(DeleteObject::class); @@ -179,9 +185,9 @@ static function (array $obj, mixed $schema, ?string $uuid, ?int $register): arra $this->createMock(ViewMapper::class), $this->objectMapper, $this->createMock(FileService::class), - $this->createMock(IUserSession::class), + $this->userSession, $this->createMock(SearchTrailService::class), - $this->createMock(IGroupManager::class), + $this->groupManager, $this->createMock(IUserManager::class), $this->createMock(OrganisationService::class), $this->createMock(LoggerInterface::class), @@ -357,6 +363,74 @@ public function testSomebodyElsesDraftIsNotDeleted(): void { } }//end testSomebodyElsesDraftIsNotDeleted() + /** + * Sign in as a user in the given groups. + * + * @param string $uid The user id. + * @param array $groups The user's groups. + * + * @return void + */ + private function signIn(string $uid, array $groups): void { + $user = $this->createMock(\OCP\IUser::class); + $user->method('getUID')->willReturn($uid); + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('getUserGroupIds')->willReturn($groups); + }//end signIn() + + /** + * The owner deletes their own draft, for good. + * + * @return void + */ + public function testTheOwnerMayDeleteTheirOwnDraft(): void { + [$register, $schema] = $this->scope(archival: false); + $this->stored(status: 'draft'); + $asked = []; + $this->recordHandler(asked: $asked); + + // The stored draft belongs to 'agent'. + $this->signIn(uid: 'agent', groups: ['staff']); + $this->assertTrue($this->service->deleteObject(uuid: 'draft-1', register: $register, schema: $schema, _rbac: true, _multitenancy: false)); + $this->assertSame([true], $asked, "the owner's own draft is deleted for good"); + }//end testTheOwnerMayDeleteTheirOwnDraft() + + /** + * An administrator is not kept from another user's draft, as on the read path. + * + * @return void + */ + public function testAnAdministratorMayDeleteSomebodyElsesDraft(): void { + [$register, $schema] = $this->scope(archival: false); + $this->stored(status: 'draft'); + $asked = []; + $this->recordHandler(asked: $asked); + + $this->signIn(uid: 'root', groups: ['admin']); + $this->assertTrue($this->service->deleteObject(uuid: 'draft-1', register: $register, schema: $schema, _rbac: true, _multitenancy: false)); + $this->assertSame([true], $asked); + }//end testAnAdministratorMayDeleteSomebodyElsesDraft() + + /** + * A non-owner who is not an administrator is still kept from the draft. + * + * @return void + */ + public function testANonOwnerNonAdministratorIsStillKept(): void { + [$register, $schema] = $this->scope(archival: false); + $this->stored(status: 'draft'); + $asked = []; + $this->recordHandler(asked: $asked); + + $this->signIn(uid: 'mallory', groups: ['staff']); + $this->expectException(\OCP\AppFramework\Db\DoesNotExistException::class); + try { + $this->service->deleteObject(uuid: 'draft-1', register: $register, schema: $schema, _rbac: true, _multitenancy: false); + } finally { + $this->assertSame([], $asked); + } + }//end testANonOwnerNonAdministratorIsStillKept() + /** * Control: an ordinary object's delete stays the soft delete it was. * From 42248e3a529e079b26410c15385a8d5e112eb90e Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Fri, 16 Oct 2026 10:33:48 +0200 Subject: [PATCH 34/37] fix(l10n): user-settings wording of the VAPID hint in the other 34 locales The admin hint now says 'user settings' (surf-f4); en and nl were updated, the other catalogues still carried the old key, so test:l10n:parity failed on a missing key in every locale. Key and value renamed in both l10n/.js and l10n/.json, with the stale justification records adjusted in scripts/l10n/locales (sv translated, ca/sr renamed). Co-Authored-By: Claude Opus 5.5 --- l10n/be.js | 2 +- l10n/be.json | 2 +- l10n/bg.js | 2 +- l10n/bg.json | 2 +- l10n/bs.js | 2 +- l10n/bs.json | 2 +- l10n/ca.js | 2 +- l10n/ca.json | 2 +- l10n/cs.js | 2 +- l10n/cs.json | 2 +- l10n/da.js | 2 +- l10n/da.json | 2 +- l10n/de.js | 2 +- l10n/de.json | 2 +- l10n/el.js | 2 +- l10n/el.json | 2 +- l10n/es.js | 2 +- l10n/es.json | 2 +- l10n/et.js | 2 +- l10n/et.json | 2 +- l10n/fi.js | 2 +- l10n/fi.json | 2 +- l10n/fr.js | 2 +- l10n/fr.json | 2 +- l10n/ga.js | 2 +- l10n/ga.json | 2 +- l10n/hr.js | 2 +- l10n/hr.json | 2 +- l10n/hu.js | 2 +- l10n/hu.json | 2 +- l10n/is.js | 2 +- l10n/is.json | 2 +- l10n/it.js | 2 +- l10n/it.json | 2 +- l10n/lb.js | 2 +- l10n/lb.json | 2 +- l10n/lt.js | 2 +- l10n/lt.json | 2 +- l10n/lv.js | 2 +- l10n/lv.json | 2 +- l10n/mk.js | 2 +- l10n/mk.json | 2 +- l10n/mt.js | 2 +- l10n/mt.json | 2 +- l10n/nb.js | 2 +- l10n/nb.json | 2 +- l10n/pl.js | 2 +- l10n/pl.json | 2 +- l10n/pt.js | 2 +- l10n/pt.json | 2 +- l10n/rm.js | 2 +- l10n/rm.json | 2 +- l10n/ro.js | 2 +- l10n/ro.json | 2 +- l10n/ru.js | 2 +- l10n/ru.json | 2 +- l10n/sk.js | 2 +- l10n/sk.json | 2 +- l10n/sl.js | 2 +- l10n/sl.json | 2 +- l10n/sq.js | 2 +- l10n/sq.json | 2 +- l10n/sr.js | 2 +- l10n/sr.json | 2 +- l10n/sv.js | 2 +- l10n/sv.json | 2 +- l10n/tr.js | 2 +- l10n/tr.json | 2 +- l10n/uk.js | 2 +- l10n/uk.json | 2 +- scripts/l10n/locales/ca.json | 2 +- scripts/l10n/locales/sr.json | 2 +- scripts/l10n/locales/sv.json | 1 - 73 files changed, 72 insertions(+), 73 deletions(-) diff --git a/l10n/be.js b/l10n/be.js index bb6a8a0854..0139d5024f 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} неудачно", "0 = unlimited": "0 = неограниченно", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Паток запускае серыю крокаў, калі нешта адбываецца — аб'ект змяняецца, спрацоўвае расклад, або вы запускаеце яго самі. Гэты спіс паказвае патокі ўсіх праграм.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Пара ключоў VAPID наладжана. Карыстальнікі могуць уключыць апавяшчэнні браўзера ва ўласных наладах; яны дастаўляюцца нават тады, калі ўкладка браўзера закрыта.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пара ключоў VAPID наладжана. Карыстальнікі могуць уключыць апавяшчэнні браўзера ў наладах карыстальніка; яны дастаўляюцца нават тады, калі ўкладка браўзера закрыта.", "A deactivation request is pending.": "Запрос на деактивацию ожидает обработки.", "AI Assistant": "ИИ-помощник", "AI Chat": "ИИ-чат", diff --git a/l10n/be.json b/l10n/be.json index 85cf08dae2..0bc0dc18c7 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} неудачно", "0 = unlimited": "0 = неограниченно", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Паток запускае серыю крокаў, калі нешта адбываецца — аб'ект змяняецца, спрацоўвае расклад, або вы запускаеце яго самі. Гэты спіс паказвае патокі ўсіх праграм.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Пара ключоў VAPID наладжана. Карыстальнікі могуць уключыць апавяшчэнні браўзера ва ўласных наладах; яны дастаўляюцца нават тады, калі ўкладка браўзера закрыта.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пара ключоў VAPID наладжана. Карыстальнікі могуць уключыць апавяшчэнні браўзера ў наладах карыстальніка; яны дастаўляюцца нават тады, калі ўкладка браўзера закрыта.", "A deactivation request is pending.": "Запрос на деактивацию ожидает обработки.", "AI Assistant": "ИИ-помощник", "AI Chat": "ИИ-чат", diff --git a/l10n/bg.js b/l10n/bg.js index 96880d23aa..adb6e4a8b3 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} неуспешни", "0 = unlimited": "0 = неограничено", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Потокът изпълнява поредица от стъпки, когато нещо се случи — обект се променя, график се задейства или го стартирате сами. Този списък показва потоците на всички приложения.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Настроена е двойка ключове VAPID. Потребителите могат да включат известията в браузъра от личните си настройки; те се доставят дори когато разделът на браузъра е затворен.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Настроена е двойка ключове VAPID. Потребителите могат да включат известията в браузъра от потребителските си настройки; те се доставят дори когато разделът на браузъра е затворен.", "A deactivation request is pending.": "Заявка за деактивиране е в очакване.", "AI Assistant": "AI асистент", "AI Chat": "AI чат", diff --git a/l10n/bg.json b/l10n/bg.json index 0e0384068c..fdb318c262 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} неуспешни", "0 = unlimited": "0 = неограничено", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Потокът изпълнява поредица от стъпки, когато нещо се случи — обект се променя, график се задейства или го стартирате сами. Този списък показва потоците на всички приложения.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Настроена е двойка ключове VAPID. Потребителите могат да включат известията в браузъра от личните си настройки; те се доставят дори когато разделът на браузъра е затворен.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Настроена е двойка ключове VAPID. Потребителите могат да включат известията в браузъра от потребителските си настройки; те се доставят дори когато разделът на браузъра е затворен.", "A deactivation request is pending.": "Заявка за деактивиране е в очакване.", "AI Assistant": "AI асистент", "AI Chat": "AI чат", diff --git a/l10n/bs.js b/l10n/bs.js index 69d4e70a2e..d6483bc4cf 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} neuspješno", "0 = unlimited": "0 = neograničeno", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto desi — objekat se promijeni, raspored se aktivira ili ga sami pokrenete. Ova lista prikazuje tokove svih aplikacija.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je konfigurisan. Korisnici mogu u ličnim postavkama uključiti obavijesti preglednika, koje se isporučuju čak i kada je kartica preglednika zatvorena.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je konfigurisan. Korisnici mogu u korisničkim postavkama uključiti obavijesti preglednika, koje se isporučuju čak i kada je kartica preglednika zatvorena.", "A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.", "AI Assistant": "AI asistent", "AI Chat": "AI razgovor", diff --git a/l10n/bs.json b/l10n/bs.json index cad03268c4..36ef584d08 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} neuspješno", "0 = unlimited": "0 = neograničeno", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto desi — objekat se promijeni, raspored se aktivira ili ga sami pokrenete. Ova lista prikazuje tokove svih aplikacija.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je konfigurisan. Korisnici mogu u ličnim postavkama uključiti obavijesti preglednika, koje se isporučuju čak i kada je kartica preglednika zatvorena.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je konfigurisan. Korisnici mogu u korisničkim postavkama uključiti obavijesti preglednika, koje se isporučuju čak i kada je kartica preglednika zatvorena.", "A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.", "AI Assistant": "AI asistent", "AI Chat": "AI razgovor", diff --git a/l10n/ca.js b/l10n/ca.js index cd056d1a01..799c7bd2d5 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} fallits", "0 = unlimited": "0 = il·limitat", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux executa una sèrie de passos quan passa alguna cosa — un objecte canvia, una programació es dispara o l'executeu vós mateix. Aquesta llista mostra els fluxos de cada aplicació.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Hi ha un parell de claus VAPID configurat. Els usuaris poden activar les notificacions del navegador des de la configuració personal; s'entreguen fins i tot quan la pestanya del navegador està tancada.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Hi ha un parell de claus VAPID configurat. Els usuaris poden activar les notificacions del navegador des de la configuració d'usuari; s'entreguen fins i tot quan la pestanya del navegador està tancada.", "A deactivation request is pending.": "Hi ha una sol·licitud de desactivació pendent.", "AI Assistant": "Assistent d'IA", "AI Chat": "Xat d'IA", diff --git a/l10n/ca.json b/l10n/ca.json index 815fbd889e..d36d735ace 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} fallits", "0 = unlimited": "0 = il·limitat", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux executa una sèrie de passos quan passa alguna cosa — un objecte canvia, una programació es dispara o l'executeu vós mateix. Aquesta llista mostra els fluxos de cada aplicació.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Hi ha un parell de claus VAPID configurat. Els usuaris poden activar les notificacions del navegador des de la configuració personal; s'entreguen fins i tot quan la pestanya del navegador està tancada.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Hi ha un parell de claus VAPID configurat. Els usuaris poden activar les notificacions del navegador des de la configuració d'usuari; s'entreguen fins i tot quan la pestanya del navegador està tancada.", "A deactivation request is pending.": "Hi ha una sol·licitud de desactivació pendent.", "AI Assistant": "Assistent d'IA", "AI Chat": "Xat d'IA", diff --git a/l10n/cs.js b/l10n/cs.js index 4c02e2f7d4..eb8eb302db 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} selhalo", "0 = unlimited": "0 = neomezeno", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok spustí sérii kroků, když se něco stane — objekt se změní, spustí se plán, nebo jej spustíte sami. Tento seznam zobrazuje toky všech aplikací.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Je nakonfigurován pár klíčů VAPID. Uživatelé si mohou v osobním nastavení zapnout oznámení v prohlížeči, která se doručují i při zavřené kartě prohlížeče.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Je nakonfigurován pár klíčů VAPID. Uživatelé si mohou v uživatelském nastavení zapnout oznámení v prohlížeči, která se doručují i při zavřené kartě prohlížeče.", "A deactivation request is pending.": "Žádost o deaktivaci čeká na vyřízení.", "AI Assistant": "AI asistent", "AI Chat": "AI chat", diff --git a/l10n/cs.json b/l10n/cs.json index 8672dcef7a..316fad442f 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} selhalo", "0 = unlimited": "0 = neomezeno", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok spustí sérii kroků, když se něco stane — objekt se změní, spustí se plán, nebo jej spustíte sami. Tento seznam zobrazuje toky všech aplikací.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Je nakonfigurován pár klíčů VAPID. Uživatelé si mohou v osobním nastavení zapnout oznámení v prohlížeči, která se doručují i při zavřené kartě prohlížeče.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Je nakonfigurován pár klíčů VAPID. Uživatelé si mohou v uživatelském nastavení zapnout oznámení v prohlížeči, která se doručují i při zavřené kartě prohlížeče.", "A deactivation request is pending.": "Žádost o deaktivaci čeká na vyřízení.", "AI Assistant": "AI asistent", "AI Chat": "AI chat", diff --git a/l10n/da.js b/l10n/da.js index d3179bd246..4822f40342 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} mislykkedes", "0 = unlimited": "0 = ubegrænset", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Et flow kører en række trin, når noget sker — et objekt ændres, en tidsplan udløses, eller du kører det selv. Denne liste viser alle appers flows.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Der er konfigureret et VAPID-nøglepar. Brugere kan tilmelde sig browsernotifikationer i deres brugerindstillinger, som leveres, selv når browserfanen er lukket.", "A deactivation request is pending.": "En anmodning om deaktivering afventer.", "AI Assistant": "AI-assistent", "AI Chat": "AI-chat", diff --git a/l10n/da.json b/l10n/da.json index 90478e08d1..f0a0344637 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} mislykkedes", "0 = unlimited": "0 = ubegrænset", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Et flow kører en række trin, når noget sker — et objekt ændres, en tidsplan udløses, eller du kører det selv. Denne liste viser alle appers flows.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Der er konfigureret et VAPID-nøglepar. Brugere kan tilmelde sig browsernotifikationer i deres brugerindstillinger, som leveres, selv når browserfanen er lukket.", "A deactivation request is pending.": "En anmodning om deaktivering afventer.", "AI Assistant": "AI-assistent", "AI Chat": "AI-chat", diff --git a/l10n/de.js b/l10n/de.js index d7d5dd17ba..ee7304ef90 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} fehlgeschlagen", "0 = unlimited": "0 = unbegrenzt", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ein Flow führt eine Reihe von Schritten aus, wenn etwas passiert — ein Objekt ändert sich, ein Zeitplan wird ausgelöst oder Sie führen ihn selbst aus. Diese Liste zeigt die Flows jeder App.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Ein VAPID-Schlüsselpaar ist konfiguriert. Benutzer können Browser-Benachrichtigungen in ihren persönlichen Einstellungen aktivieren; diese werden auch dann zugestellt, wenn der Browser-Tab geschlossen ist.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Ein VAPID-Schlüsselpaar ist konfiguriert. Benutzer können Browser-Benachrichtigungen in ihren Benutzereinstellungen aktivieren; diese werden auch dann zugestellt, wenn der Browser-Tab geschlossen ist.", "A deactivation request is pending.": "Eine Deaktivierungsanfrage ist ausstehend.", "AI Assistant": "KI-Assistent", "AI Chat": "KI-Chat", diff --git a/l10n/de.json b/l10n/de.json index f9620c388e..6faeab6db4 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} fehlgeschlagen", "0 = unlimited": "0 = unbegrenzt", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ein Flow führt eine Reihe von Schritten aus, wenn etwas passiert — ein Objekt ändert sich, ein Zeitplan wird ausgelöst oder Sie führen ihn selbst aus. Diese Liste zeigt die Flows jeder App.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Ein VAPID-Schlüsselpaar ist konfiguriert. Benutzer können Browser-Benachrichtigungen in ihren persönlichen Einstellungen aktivieren; diese werden auch dann zugestellt, wenn der Browser-Tab geschlossen ist.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Ein VAPID-Schlüsselpaar ist konfiguriert. Benutzer können Browser-Benachrichtigungen in ihren Benutzereinstellungen aktivieren; diese werden auch dann zugestellt, wenn der Browser-Tab geschlossen ist.", "A deactivation request is pending.": "Eine Deaktivierungsanfrage ist ausstehend.", "AI Assistant": "KI-Assistent", "AI Chat": "KI-Chat", diff --git a/l10n/el.js b/l10n/el.js index ee09a21146..2f956f6139 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} απέτυχαν", "0 = unlimited": "0 = απεριόριστο", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Μια ροή εκτελεί μια σειρά βημάτων όταν συμβεί κάτι — ένα αντικείμενο αλλάζει, ένα χρονοδιάγραμμα ενεργοποιείται ή την εκτελείτε εσείς οι ίδιοι. Αυτή η λίστα εμφανίζει τις ροές κάθε εφαρμογής.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Έχει ρυθμιστεί ένα ζεύγος κλειδιών VAPID. Οι χρήστες μπορούν να ενεργοποιήσουν τις ειδοποιήσεις του προγράμματος περιήγησης από τις ρυθμίσεις χρήστη τους· παραδίδονται ακόμη και όταν η καρτέλα του προγράμματος περιήγησης είναι κλειστή.", "A deactivation request is pending.": "Εκκρεμεί ένα αίτημα απενεργοποίησης.", "AI Assistant": "Βοηθός AI", "AI Chat": "Συνομιλία AI", diff --git a/l10n/el.json b/l10n/el.json index ad01251066..2934d1da20 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} απέτυχαν", "0 = unlimited": "0 = απεριόριστο", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Μια ροή εκτελεί μια σειρά βημάτων όταν συμβεί κάτι — ένα αντικείμενο αλλάζει, ένα χρονοδιάγραμμα ενεργοποιείται ή την εκτελείτε εσείς οι ίδιοι. Αυτή η λίστα εμφανίζει τις ροές κάθε εφαρμογής.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Έχει ρυθμιστεί ένα ζεύγος κλειδιών VAPID. Οι χρήστες μπορούν να ενεργοποιήσουν τις ειδοποιήσεις του προγράμματος περιήγησης από τις ρυθμίσεις χρήστη τους· παραδίδονται ακόμη και όταν η καρτέλα του προγράμματος περιήγησης είναι κλειστή.", "A deactivation request is pending.": "Εκκρεμεί ένα αίτημα απενεργοποίησης.", "AI Assistant": "Βοηθός AI", "AI Chat": "Συνομιλία AI", diff --git a/l10n/es.js b/l10n/es.js index 38f1bb0cef..21575bb408 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} fallidos", "0 = unlimited": "0 = ilimitado", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flujo ejecuta una serie de pasos cuando ocurre algo — un objeto cambia, una programación se activa o usted mismo lo ejecuta. Esta lista muestra los flujos de cada aplicación.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Hay configurado un par de claves VAPID. Los usuarios pueden activar las notificaciones del navegador desde sus ajustes personales; se entregan incluso con la pestaña del navegador cerrada.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Hay configurado un par de claves VAPID. Los usuarios pueden activar las notificaciones del navegador desde sus ajustes de usuario; se entregan incluso con la pestaña del navegador cerrada.", "A deactivation request is pending.": "Hay una solicitud de desactivación pendiente.", "AI Assistant": "Asistente de IA", "AI Chat": "Chat de IA", diff --git a/l10n/es.json b/l10n/es.json index e50617a17e..c8b8933386 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} fallidos", "0 = unlimited": "0 = ilimitado", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flujo ejecuta una serie de pasos cuando ocurre algo — un objeto cambia, una programación se activa o usted mismo lo ejecuta. Esta lista muestra los flujos de cada aplicación.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Hay configurado un par de claves VAPID. Los usuarios pueden activar las notificaciones del navegador desde sus ajustes personales; se entregan incluso con la pestaña del navegador cerrada.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Hay configurado un par de claves VAPID. Los usuarios pueden activar las notificaciones del navegador desde sus ajustes de usuario; se entregan incluso con la pestaña del navegador cerrada.", "A deactivation request is pending.": "Hay una solicitud de desactivación pendiente.", "AI Assistant": "Asistente de IA", "AI Chat": "Chat de IA", diff --git a/l10n/et.js b/l10n/et.js index 84a153530c..4f64298677 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} ebaõnnestus", "0 = unlimited": "0 = piiramatu", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Voog käivitab rea samme, kui midagi juhtub — objekt muutub, ajakava rakendub või käivitad selle ise. See loend näitab iga rakenduse vooge.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID võtmepaar on seadistatud. Kasutajad saavad brauseriteavitused oma isiklikes sätetes sisse lülitada; need toimetatakse kohale ka siis, kui brauseri kaart on suletud.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID võtmepaar on seadistatud. Kasutajad saavad brauseriteavitused oma kasutajasätetes sisse lülitada; need toimetatakse kohale ka siis, kui brauseri kaart on suletud.", "A deactivation request is pending.": "Deaktiveerimistaotlus on ootel.", "AI Assistant": "AI-assistent", "AI Chat": "AI-vestlus", diff --git a/l10n/et.json b/l10n/et.json index e09dafb8dd..22f5175d2a 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} ebaõnnestus", "0 = unlimited": "0 = piiramatu", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Voog käivitab rea samme, kui midagi juhtub — objekt muutub, ajakava rakendub või käivitad selle ise. See loend näitab iga rakenduse vooge.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID võtmepaar on seadistatud. Kasutajad saavad brauseriteavitused oma isiklikes sätetes sisse lülitada; need toimetatakse kohale ka siis, kui brauseri kaart on suletud.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID võtmepaar on seadistatud. Kasutajad saavad brauseriteavitused oma kasutajasätetes sisse lülitada; need toimetatakse kohale ka siis, kui brauseri kaart on suletud.", "A deactivation request is pending.": "Deaktiveerimistaotlus on ootel.", "AI Assistant": "AI-assistent", "AI Chat": "AI-vestlus", diff --git a/l10n/fi.js b/l10n/fi.js index 6ee565dede..4a04130562 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} epäonnistui", "0 = unlimited": "0 = rajoittamaton", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Vuo suorittaa sarjan vaiheita, kun jotain tapahtuu — objekti muuttuu, aikataulu laukeaa tai suoritat sen itse. Tämä luettelo näyttää jokaisen sovelluksen vuot.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID-avainpari on määritetty. Käyttäjät voivat ottaa selainilmoitukset käyttöön käyttäjäasetuksissaan, ja ne toimitetaan, vaikka selaimen välilehti olisi suljettu.", "A deactivation request is pending.": "Deaktivointipyyntö on odottamassa.", "AI Assistant": "Tekoälyavustaja", "AI Chat": "Tekoälykeskustelu", diff --git a/l10n/fi.json b/l10n/fi.json index 25aff9ee61..fb3519bef4 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} epäonnistui", "0 = unlimited": "0 = rajoittamaton", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Vuo suorittaa sarjan vaiheita, kun jotain tapahtuu — objekti muuttuu, aikataulu laukeaa tai suoritat sen itse. Tämä luettelo näyttää jokaisen sovelluksen vuot.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID-avainpari on määritetty. Käyttäjät voivat ottaa selainilmoitukset käyttöön käyttäjäasetuksissaan, ja ne toimitetaan, vaikka selaimen välilehti olisi suljettu.", "A deactivation request is pending.": "Deaktivointipyyntö on odottamassa.", "AI Assistant": "Tekoälyavustaja", "AI Chat": "Tekoälykeskustelu", diff --git a/l10n/fr.js b/l10n/fr.js index 4ce02c8562..e8ea4fd6d4 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} échoué(s)", "0 = unlimited": "0 = illimité", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux exécute une série d'étapes lorsqu'un événement se produit — un objet change, une planification se déclenche, ou vous l'exécutez vous-même. Cette liste affiche les flux de chaque application.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Une paire de clés VAPID est configurée. Les utilisateurs peuvent activer les notifications du navigateur depuis leurs paramètres personnels ; elles sont remises même lorsque l'onglet du navigateur est fermé.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Une paire de clés VAPID est configurée. Les utilisateurs peuvent activer les notifications du navigateur depuis leurs paramètres utilisateur ; elles sont remises même lorsque l'onglet du navigateur est fermé.", "A deactivation request is pending.": "Une demande de désactivation est en attente.", "AI Assistant": "Assistant IA", "AI Chat": "Chat IA", diff --git a/l10n/fr.json b/l10n/fr.json index 637fc471f3..f1ed31541c 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} échoué(s)", "0 = unlimited": "0 = illimité", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux exécute une série d'étapes lorsqu'un événement se produit — un objet change, une planification se déclenche, ou vous l'exécutez vous-même. Cette liste affiche les flux de chaque application.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Une paire de clés VAPID est configurée. Les utilisateurs peuvent activer les notifications du navigateur depuis leurs paramètres personnels ; elles sont remises même lorsque l'onglet du navigateur est fermé.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Une paire de clés VAPID est configurée. Les utilisateurs peuvent activer les notifications du navigateur depuis leurs paramètres utilisateur ; elles sont remises même lorsque l'onglet du navigateur est fermé.", "A deactivation request is pending.": "Une demande de désactivation est en attente.", "AI Assistant": "Assistant IA", "AI Chat": "Chat IA", diff --git a/l10n/ga.js b/l10n/ga.js index be77c4ad55..c074ce8145 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", theip ar {failed}", "0 = unlimited": "0 = gan teorainn", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ritheann sruth sraith céimeanna nuair a tharlaíonn rud éigin — athraíonn oibiacht, lasann sceideal, nó ritheann tú féin é. Taispeánann an liosta seo sruthanna gach aipe.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Tá péire eochracha VAPID cumraithe. Is féidir le húsáideoirí fógraí brabhsálaí a roghnú ina socruithe pearsanta, agus seachadtar iad fiú nuair a bhíonn cluaisín an bhrabhsálaí dúnta.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Tá péire eochracha VAPID cumraithe. Is féidir le húsáideoirí fógraí brabhsálaí a roghnú ina socruithe úsáideora, agus seachadtar iad fiú nuair a bhíonn cluaisín an bhrabhsálaí dúnta.", "A deactivation request is pending.": "Tá iarratas díghníomhachtaithe ar feitheamh.", "AI Assistant": "Cúntóir AI", "AI Chat": "Comhrá AI", diff --git a/l10n/ga.json b/l10n/ga.json index dfa5b4c675..840d5ff932 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -43,7 +43,7 @@ ", {failed} failed": ", theip ar {failed}", "0 = unlimited": "0 = gan teorainn", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ritheann sruth sraith céimeanna nuair a tharlaíonn rud éigin — athraíonn oibiacht, lasann sceideal, nó ritheann tú féin é. Taispeánann an liosta seo sruthanna gach aipe.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Tá péire eochracha VAPID cumraithe. Is féidir le húsáideoirí fógraí brabhsálaí a roghnú ina socruithe pearsanta, agus seachadtar iad fiú nuair a bhíonn cluaisín an bhrabhsálaí dúnta.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Tá péire eochracha VAPID cumraithe. Is féidir le húsáideoirí fógraí brabhsálaí a roghnú ina socruithe úsáideora, agus seachadtar iad fiú nuair a bhíonn cluaisín an bhrabhsálaí dúnta.", "A deactivation request is pending.": "Tá iarratas díghníomhachtaithe ar feitheamh.", "AI Assistant": "Cúntóir AI", "AI Chat": "Comhrá AI", diff --git a/l10n/hr.js b/l10n/hr.js index e89bffbf04..58563390c8 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} neuspješno", "0 = unlimited": "0 = neograničeno", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto dogodi — objekt se promijeni, raspored se aktivira ili ga sami pokrenete. Ovaj popis prikazuje tokove svih aplikacija.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ključeva VAPID je konfiguriran. Korisnici mogu u osobnim postavkama uključiti obavijesti preglednika, koje se isporučuju i kada je kartica preglednika zatvorena.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ključeva VAPID je konfiguriran. Korisnici mogu u korisničkim postavkama uključiti obavijesti preglednika, koje se isporučuju i kada je kartica preglednika zatvorena.", "A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.", "AI Assistant": "AI asistent", "AI Chat": "AI razgovor", diff --git a/l10n/hr.json b/l10n/hr.json index 941e70da55..1c46704b98 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} neuspješno", "0 = unlimited": "0 = neograničeno", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto dogodi — objekt se promijeni, raspored se aktivira ili ga sami pokrenete. Ovaj popis prikazuje tokove svih aplikacija.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ključeva VAPID je konfiguriran. Korisnici mogu u osobnim postavkama uključiti obavijesti preglednika, koje se isporučuju i kada je kartica preglednika zatvorena.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ključeva VAPID je konfiguriran. Korisnici mogu u korisničkim postavkama uključiti obavijesti preglednika, koje se isporučuju i kada je kartica preglednika zatvorena.", "A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.", "AI Assistant": "AI asistent", "AI Chat": "AI razgovor", diff --git a/l10n/hu.js b/l10n/hu.js index 4f7ae600f8..65d9f18b0f 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} sikertelen", "0 = unlimited": "0 = korlátlan", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Egy folyamat lépések sorozatát futtatja, amikor történik valami — egy objektum megváltozik, egy ütemezés elindul, vagy Ön maga futtatja. Ez a lista minden alkalmazás folyamatait mutatja.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "A VAPID-kulcspár be van állítva. A felhasználók a felhasználói beállításaikban kapcsolhatják be a böngészőértesítéseket, amelyek akkor is kézbesítésre kerülnek, ha a böngészőfül be van zárva.", "A deactivation request is pending.": "Egy deaktiválási kérelem függőben van.", "AI Assistant": "AI asszisztens", "AI Chat": "AI csevegés", diff --git a/l10n/hu.json b/l10n/hu.json index fe68d07528..ae131fd360 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} sikertelen", "0 = unlimited": "0 = korlátlan", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Egy folyamat lépések sorozatát futtatja, amikor történik valami — egy objektum megváltozik, egy ütemezés elindul, vagy Ön maga futtatja. Ez a lista minden alkalmazás folyamatait mutatja.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "A VAPID-kulcspár be van állítva. A felhasználók a felhasználói beállításaikban kapcsolhatják be a böngészőértesítéseket, amelyek akkor is kézbesítésre kerülnek, ha a böngészőfül be van zárva.", "A deactivation request is pending.": "Egy deaktiválási kérelem függőben van.", "AI Assistant": "AI asszisztens", "AI Chat": "AI csevegés", diff --git a/l10n/is.js b/l10n/is.js index 8d99a0d3a5..82b641a511 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} mishepnuð", "0 = unlimited": "0 = ótakmarkað", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Flæði keyrir röð skrefa þegar eitthvað gerist — hlutur breytist, tímaáætlun fer í gang eða þú keyrir það sjálft. Þessi listi sýnir flæði allra forrita.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID-lyklapar er stillt. Notendur geta virkjað tilkynningar í vafra í persónulegum stillingum sínum og þær berast jafnvel þegar flipinn í vafranum er lokaður.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID-lyklapar er stillt. Notendur geta virkjað tilkynningar í vafra í notandastillingum sínum og þær berast jafnvel þegar flipinn í vafranum er lokaður.", "A deactivation request is pending.": "Beiðni um óvirkjun er í bið.", "AI Assistant": "AI aðstoðarmaður", "AI Chat": "AI spjall", diff --git a/l10n/is.json b/l10n/is.json index 0fc333f72f..1fc9fa6ce3 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} mishepnuð", "0 = unlimited": "0 = ótakmarkað", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Flæði keyrir röð skrefa þegar eitthvað gerist — hlutur breytist, tímaáætlun fer í gang eða þú keyrir það sjálft. Þessi listi sýnir flæði allra forrita.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID-lyklapar er stillt. Notendur geta virkjað tilkynningar í vafra í persónulegum stillingum sínum og þær berast jafnvel þegar flipinn í vafranum er lokaður.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID-lyklapar er stillt. Notendur geta virkjað tilkynningar í vafra í notandastillingum sínum og þær berast jafnvel þegar flipinn í vafranum er lokaður.", "A deactivation request is pending.": "Beiðni um óvirkjun er í bið.", "AI Assistant": "AI aðstoðarmaður", "AI Chat": "AI spjall", diff --git a/l10n/it.js b/l10n/it.js index e8b4e2a4e6..60f7a2fa5f 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} non riusciti", "0 = unlimited": "0 = illimitato", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flusso esegue una serie di passaggi quando accade qualcosa — un oggetto cambia, una pianificazione scatta oppure lo si esegue manualmente. Questo elenco mostra i flussi di ogni app.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "È configurata una coppia di chiavi VAPID. Gli utenti possono attivare le notifiche del browser dalle impostazioni personali; vengono recapitate anche quando la scheda del browser è chiusa.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "È configurata una coppia di chiavi VAPID. Gli utenti possono attivare le notifiche del browser dalle impostazioni utente; vengono recapitate anche quando la scheda del browser è chiusa.", "A deactivation request is pending.": "È in sospeso una richiesta di disattivazione.", "AI Assistant": "Assistente IA", "AI Chat": "Chat IA", diff --git a/l10n/it.json b/l10n/it.json index 08c819be46..6513598d0b 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} non riusciti", "0 = unlimited": "0 = illimitato", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flusso esegue una serie di passaggi quando accade qualcosa — un oggetto cambia, una pianificazione scatta oppure lo si esegue manualmente. Questo elenco mostra i flussi di ogni app.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "È configurata una coppia di chiavi VAPID. Gli utenti possono attivare le notifiche del browser dalle impostazioni personali; vengono recapitate anche quando la scheda del browser è chiusa.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "È configurata una coppia di chiavi VAPID. Gli utenti possono attivare le notifiche del browser dalle impostazioni utente; vengono recapitate anche quando la scheda del browser è chiusa.", "A deactivation request is pending.": "È in sospeso una richiesta di disattivazione.", "AI Assistant": "Assistente IA", "AI Chat": "Chat IA", diff --git a/l10n/lb.js b/l10n/lb.js index 6bb48b1f6c..b5004f658d 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} mislukt", "0 = unlimited": "0 = onbeperkt", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "E Flux féiert eng Rei vu Schrëtt aus, wann eppes geschitt — en Objet ännert sech, en Zäitplang gëtt ausgeléist oder Dir féiert en selwer aus. Dës Lëscht weist d'Fluxe vun all App.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "E VAPID-Schlësselpaar ass konfiguréiert. Benotzer kënnen a hire perséinlechen Astellunge Browser-Notifikatiounen aktivéieren, déi och geliwwert ginn, wann de Browser-Tab zou ass.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "E VAPID-Schlësselpaar ass konfiguréiert. Benotzer kënnen a hire Benotzerastellunge Browser-Notifikatiounen aktivéieren, déi och geliwwert ginn, wann de Browser-Tab zou ass.", "A deactivation request is pending.": "Er is een deactiveringsverzoek in behandeling.", "AI Assistant": "KI-Assistent", "AI Chat": "KI-Chat", diff --git a/l10n/lb.json b/l10n/lb.json index 3257d19301..f8e1fd1c94 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} mislukt", "0 = unlimited": "0 = onbeperkt", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "E Flux féiert eng Rei vu Schrëtt aus, wann eppes geschitt — en Objet ännert sech, en Zäitplang gëtt ausgeléist oder Dir féiert en selwer aus. Dës Lëscht weist d'Fluxe vun all App.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "E VAPID-Schlësselpaar ass konfiguréiert. Benotzer kënnen a hire perséinlechen Astellunge Browser-Notifikatiounen aktivéieren, déi och geliwwert ginn, wann de Browser-Tab zou ass.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "E VAPID-Schlësselpaar ass konfiguréiert. Benotzer kënnen a hire Benotzerastellunge Browser-Notifikatiounen aktivéieren, déi och geliwwert ginn, wann de Browser-Tab zou ass.", "A deactivation request is pending.": "Er is een deactiveringsverzoek in behandeling.", "AI Assistant": "KI-Assistent", "AI Chat": "KI-Chat", diff --git a/l10n/lt.js b/l10n/lt.js index 53d503f132..8644d76724 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", nepavyko: {failed}", "0 = unlimited": "0 = neribota", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Srautas vykdo veiksmų seką, kai kas nors įvyksta — pasikeičia objektas, suveikia tvarkaraštis arba paleidžiate jį patys. Šiame sąraše rodomi visų programų srautai.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID raktų pora sukonfigūruota. Naudotojai savo asmeniniuose nustatymuose gali įjungti naršyklės pranešimus, kurie pristatomi net ir tada, kai naršyklės kortelė uždaryta.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID raktų pora sukonfigūruota. Naudotojai savo naudotojo nustatymuose gali įjungti naršyklės pranešimus, kurie pristatomi net ir tada, kai naršyklės kortelė uždaryta.", "A deactivation request is pending.": "Laukiama deaktyvavimo užklausa.", "AI Assistant": "DI asistentas", "AI Chat": "DI pokalbis", diff --git a/l10n/lt.json b/l10n/lt.json index 8dc7ac6498..cda729d6a5 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -43,7 +43,7 @@ ", {failed} failed": ", nepavyko: {failed}", "0 = unlimited": "0 = neribota", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Srautas vykdo veiksmų seką, kai kas nors įvyksta — pasikeičia objektas, suveikia tvarkaraštis arba paleidžiate jį patys. Šiame sąraše rodomi visų programų srautai.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID raktų pora sukonfigūruota. Naudotojai savo asmeniniuose nustatymuose gali įjungti naršyklės pranešimus, kurie pristatomi net ir tada, kai naršyklės kortelė uždaryta.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID raktų pora sukonfigūruota. Naudotojai savo naudotojo nustatymuose gali įjungti naršyklės pranešimus, kurie pristatomi net ir tada, kai naršyklės kortelė uždaryta.", "A deactivation request is pending.": "Laukiama deaktyvavimo užklausa.", "AI Assistant": "DI asistentas", "AI Chat": "DI pokalbis", diff --git a/l10n/lv.js b/l10n/lv.js index ff06fb429f..9ee0d32d4d 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} neizdevās", "0 = unlimited": "0 = neierobežots", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Plūsma izpilda darbību virkni, kad kaut kas notiek — mainās objekts, nostrādā grafiks vai jūs to palaižat paši. Šis saraksts rāda visu lietotņu plūsmas.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID atslēgu pāris ir konfigurēts. Lietotāji savos personīgajos iestatījumos var ieslēgt pārlūka paziņojumus, kas tiek piegādāti arī tad, kad pārlūka cilne ir aizvērta.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID atslēgu pāris ir konfigurēts. Lietotāji savos lietotāja iestatījumos var ieslēgt pārlūka paziņojumus, kas tiek piegādāti arī tad, kad pārlūka cilne ir aizvērta.", "A deactivation request is pending.": "Deaktivizācijas pieprasījums gaida apstrādi.", "AI Assistant": "MI asistents", "AI Chat": "MI tērzēšana", diff --git a/l10n/lv.json b/l10n/lv.json index 26cfe9e189..bc1738852d 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} neizdevās", "0 = unlimited": "0 = neierobežots", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Plūsma izpilda darbību virkni, kad kaut kas notiek — mainās objekts, nostrādā grafiks vai jūs to palaižat paši. Šis saraksts rāda visu lietotņu plūsmas.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID atslēgu pāris ir konfigurēts. Lietotāji savos personīgajos iestatījumos var ieslēgt pārlūka paziņojumus, kas tiek piegādāti arī tad, kad pārlūka cilne ir aizvērta.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID atslēgu pāris ir konfigurēts. Lietotāji savos lietotāja iestatījumos var ieslēgt pārlūka paziņojumus, kas tiek piegādāti arī tad, kad pārlūka cilne ir aizvērta.", "A deactivation request is pending.": "Deaktivizācijas pieprasījums gaida apstrādi.", "AI Assistant": "MI asistents", "AI Chat": "MI tērzēšana", diff --git a/l10n/mk.js b/l10n/mk.js index 2abb5e6a7e..03388d6193 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} неуспешни", "0 = unlimited": "0 = неограничено", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Текот извршува низа чекори кога ќе се случи нешто — објект се менува, распоред се активира или сами го стартувате. Оваа листа ги прикажува тековите на сите апликации.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Конфигуриран е пар VAPID клучеви. Корисниците можат во личните поставки да ги вклучат известувањата од прелистувачот, кои се испорачуваат дури и кога картичката на прелистувачот е затворена.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Конфигуриран е пар VAPID клучеви. Корисниците можат во корисничките поставки да ги вклучат известувањата од прелистувачот, кои се испорачуваат дури и кога картичката на прелистувачот е затворена.", "A deactivation request is pending.": "Барањето за деактивација е на чекање.", "AI Assistant": "AI асистент", "AI Chat": "AI разговор", diff --git a/l10n/mk.json b/l10n/mk.json index 9b0946193c..26a9f0efc5 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} неуспешни", "0 = unlimited": "0 = неограничено", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Текот извршува низа чекори кога ќе се случи нешто — објект се менува, распоред се активира или сами го стартувате. Оваа листа ги прикажува тековите на сите апликации.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Конфигуриран е пар VAPID клучеви. Корисниците можат во личните поставки да ги вклучат известувањата од прелистувачот, кои се испорачуваат дури и кога картичката на прелистувачот е затворена.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Конфигуриран е пар VAPID клучеви. Корисниците можат во корисничките поставки да ги вклучат известувањата од прелистувачот, кои се испорачуваат дури и кога картичката на прелистувачот е затворена.", "A deactivation request is pending.": "Барањето за деактивација е на чекање.", "AI Assistant": "AI асистент", "AI Chat": "AI разговор", diff --git a/l10n/mt.js b/l10n/mt.js index fa40858b53..6b9085d7e2 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} fallew", "0 = unlimited": "0 = bla limitu", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Fluss imexxi sensiela ta' passi meta jiġri xi ħaġa — oġġett jinbidel, skeda tiskatta, jew tmexxih int stess. Din il-lista turi l-flussi ta' kull app.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ta' ċwievet VAPID huwa kkonfigurat. L-utenti jistgħu jattivaw in-notifiki tal-browser mill-issettjar personali tagħhom, u dawn jiġu kkunsinnati anke meta t-tab tal-browser ikun magħluq.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ta' ċwievet VAPID huwa kkonfigurat. L-utenti jistgħu jattivaw in-notifiki tal-browser mill-issettjar tal-utent tagħhom, u dawn jiġu kkunsinnati anke meta t-tab tal-browser ikun magħluq.", "A deactivation request is pending.": "Talba għad-diżattivazzjoni qiegħda pendenti.", "AI Assistant": "Assistent tal-IA", "AI Chat": "Chat tal-IA", diff --git a/l10n/mt.json b/l10n/mt.json index 4b8b11a1f9..871d1736e3 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} fallew", "0 = unlimited": "0 = bla limitu", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Fluss imexxi sensiela ta' passi meta jiġri xi ħaġa — oġġett jinbidel, skeda tiskatta, jew tmexxih int stess. Din il-lista turi l-flussi ta' kull app.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ta' ċwievet VAPID huwa kkonfigurat. L-utenti jistgħu jattivaw in-notifiki tal-browser mill-issettjar personali tagħhom, u dawn jiġu kkunsinnati anke meta t-tab tal-browser ikun magħluq.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ta' ċwievet VAPID huwa kkonfigurat. L-utenti jistgħu jattivaw in-notifiki tal-browser mill-issettjar tal-utent tagħhom, u dawn jiġu kkunsinnati anke meta t-tab tal-browser ikun magħluq.", "A deactivation request is pending.": "Talba għad-diżattivazzjoni qiegħda pendenti.", "AI Assistant": "Assistent tal-IA", "AI Chat": "Chat tal-IA", diff --git a/l10n/nb.js b/l10n/nb.js index c5e2b445b5..84cb4d74fa 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} mislyktes", "0 = unlimited": "0 = ubegrenset", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "En flyt kjører en rekke trinn når noe skjer — et objekt endres, en tidsplan utløses, eller du kjører den selv. Denne listen viser alle appers flyter.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Et VAPID-nøkkelpar er konfigurert. Brukere kan slå på nettleservarsler i brukerinnstillingene sine, og varslene leveres selv når nettleserfanen er lukket.", "A deactivation request is pending.": "En deaktiveringsforespørsel venter.", "AI Assistant": "AI-assistent", "AI Chat": "AI-chat", diff --git a/l10n/nb.json b/l10n/nb.json index 75c292baec..fa889a0eef 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} mislyktes", "0 = unlimited": "0 = ubegrenset", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "En flyt kjører en rekke trinn når noe skjer — et objekt endres, en tidsplan utløses, eller du kjører den selv. Denne listen viser alle appers flyter.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Et VAPID-nøkkelpar er konfigurert. Brukere kan slå på nettleservarsler i brukerinnstillingene sine, og varslene leveres selv når nettleserfanen er lukket.", "A deactivation request is pending.": "En deaktiveringsforespørsel venter.", "AI Assistant": "AI-assistent", "AI Chat": "AI-chat", diff --git a/l10n/pl.js b/l10n/pl.js index f91f12fb6e..1083c25184 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", nieudane: {failed}", "0 = unlimited": "0 = bez ograniczeń", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Przepływ wykonuje serię kroków, gdy coś się wydarzy — obiekt się zmienia, uruchamia się harmonogram lub uruchamiasz go samodzielnie. Ta lista pokazuje przepływy każdej aplikacji.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Skonfigurowano parę kluczy VAPID. Użytkownicy mogą włączyć powiadomienia przeglądarki w swoich ustawieniach użytkownika; są one dostarczane nawet wtedy, gdy karta przeglądarki jest zamknięta.", "A deactivation request is pending.": "Oczekuje wniosek o dezaktywację.", "AI Assistant": "Asystent AI", "AI Chat": "Czat AI", diff --git a/l10n/pl.json b/l10n/pl.json index 038420280d..6845312565 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -43,7 +43,7 @@ ", {failed} failed": ", nieudane: {failed}", "0 = unlimited": "0 = bez ograniczeń", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Przepływ wykonuje serię kroków, gdy coś się wydarzy — obiekt się zmienia, uruchamia się harmonogram lub uruchamiasz go samodzielnie. Ta lista pokazuje przepływy każdej aplikacji.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Skonfigurowano parę kluczy VAPID. Użytkownicy mogą włączyć powiadomienia przeglądarki w swoich ustawieniach użytkownika; są one dostarczane nawet wtedy, gdy karta przeglądarki jest zamknięta.", "A deactivation request is pending.": "Oczekuje wniosek o dezaktywację.", "AI Assistant": "Asystent AI", "AI Chat": "Czat AI", diff --git a/l10n/pt.js b/l10n/pt.js index 0a5bc565fd..b4cb5262d6 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} falharam", "0 = unlimited": "0 = ilimitado", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Um fluxo executa uma série de passos quando algo acontece — um objeto muda, um agendamento dispara ou o próprio utilizador o executa. Esta lista mostra os fluxos de cada aplicação.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Está configurado um par de chaves VAPID. Os utilizadores podem ativar as notificações do navegador nas definições pessoais; são entregues mesmo com o separador do navegador fechado.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Está configurado um par de chaves VAPID. Os utilizadores podem ativar as notificações do navegador nas definições de utilizador; são entregues mesmo com o separador do navegador fechado.", "A deactivation request is pending.": "Existe um pedido de desativação pendente.", "AI Assistant": "Assistente de IA", "AI Chat": "Conversa de IA", diff --git a/l10n/pt.json b/l10n/pt.json index 630b4546c6..f1492a3459 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} falharam", "0 = unlimited": "0 = ilimitado", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Um fluxo executa uma série de passos quando algo acontece — um objeto muda, um agendamento dispara ou o próprio utilizador o executa. Esta lista mostra os fluxos de cada aplicação.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Está configurado um par de chaves VAPID. Os utilizadores podem ativar as notificações do navegador nas definições pessoais; são entregues mesmo com o separador do navegador fechado.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Está configurado um par de chaves VAPID. Os utilizadores podem ativar as notificações do navegador nas definições de utilizador; são entregues mesmo com o separador do navegador fechado.", "A deactivation request is pending.": "Existe um pedido de desativação pendente.", "AI Assistant": "Assistente de IA", "AI Chat": "Conversa de IA", diff --git a/l10n/rm.js b/l10n/rm.js index db804b3995..b42f54bc0d 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} falharam", "0 = unlimited": "0 = ilimitado", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "In flux exequescha ina retscha da pass, cur ch'insatge capita — in object sa mida, in urari vegn activà u l'utilisader al exequescha sez. Questa glista mussa ils fluxs da mintga applicaziun.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "In pèr da clavs VAPID è configurà. Ils utilisaders pon s'annunziar per las communicaziuns dal navigatur en lur parameters persunals, che vegnan furnidas era cur ch'il tab dal navigatur è serrà.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "In pèr da clavs VAPID è configurà. Ils utilisaders pon s'annunziar per las communicaziuns dal navigatur en lur parameters d'utilisader, che vegnan furnidas era cur ch'il tab dal navigatur è serrà.", "A deactivation request is pending.": "Existe um pedido de desativação pendente.", "AI Assistant": "Assistent IA", "AI Chat": "Chat IA", diff --git a/l10n/rm.json b/l10n/rm.json index c6c5e41d7d..db72e154b5 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} falharam", "0 = unlimited": "0 = ilimitado", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "In flux exequescha ina retscha da pass, cur ch'insatge capita — in object sa mida, in urari vegn activà u l'utilisader al exequescha sez. Questa glista mussa ils fluxs da mintga applicaziun.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "In pèr da clavs VAPID è configurà. Ils utilisaders pon s'annunziar per las communicaziuns dal navigatur en lur parameters persunals, che vegnan furnidas era cur ch'il tab dal navigatur è serrà.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "In pèr da clavs VAPID è configurà. Ils utilisaders pon s'annunziar per las communicaziuns dal navigatur en lur parameters d'utilisader, che vegnan furnidas era cur ch'il tab dal navigatur è serrà.", "A deactivation request is pending.": "Existe um pedido de desativação pendente.", "AI Assistant": "Assistent IA", "AI Chat": "Chat IA", diff --git a/l10n/ro.js b/l10n/ro.js index 455a22a8bf..11de284a33 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} eșuate", "0 = unlimited": "0 = nelimitat", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux rulează o serie de pași atunci când se întâmplă ceva — un obiect se modifică, o programare se declanșează sau îl rulați chiar dvs. Această listă afișează fluxurile fiecărei aplicații.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "O pereche de chei VAPID este configurată. Utilizatorii pot activa notificările din browser în setările personale; acestea sunt livrate chiar și când fila browserului este închisă.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "O pereche de chei VAPID este configurată. Utilizatorii pot activa notificările din browser în setările de utilizator; acestea sunt livrate chiar și când fila browserului este închisă.", "A deactivation request is pending.": "O cerere de dezactivare este în așteptare.", "AI Assistant": "Asistent AI", "AI Chat": "Conversație AI", diff --git a/l10n/ro.json b/l10n/ro.json index 03c60cff7a..a2bdea669e 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} eșuate", "0 = unlimited": "0 = nelimitat", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux rulează o serie de pași atunci când se întâmplă ceva — un obiect se modifică, o programare se declanșează sau îl rulați chiar dvs. Această listă afișează fluxurile fiecărei aplicații.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "O pereche de chei VAPID este configurată. Utilizatorii pot activa notificările din browser în setările personale; acestea sunt livrate chiar și când fila browserului este închisă.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "O pereche de chei VAPID este configurată. Utilizatorii pot activa notificările din browser în setările de utilizator; acestea sunt livrate chiar și când fila browserului este închisă.", "A deactivation request is pending.": "O cerere de dezactivare este în așteptare.", "AI Assistant": "Asistent AI", "AI Chat": "Conversație AI", diff --git a/l10n/ru.js b/l10n/ru.js index 6b429b77f7..5ad93db803 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} неудачно", "0 = unlimited": "0 = неограниченно", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Поток запускает серию шагов, когда что-то происходит — объект изменяется, срабатывает расписание или вы запускаете его сами. Этот список показывает потоки всех приложений.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пара ключей VAPID настроена. Пользователи могут включить уведомления браузера в настройках пользователя; они доставляются, даже когда вкладка браузера закрыта.", "A deactivation request is pending.": "Запрос на деактивацию ожидает обработки.", "AI Assistant": "ИИ-помощник", "AI Chat": "ИИ-чат", diff --git a/l10n/ru.json b/l10n/ru.json index 8613070f5e..f006f8f2b5 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} неудачно", "0 = unlimited": "0 = неограниченно", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Поток запускает серию шагов, когда что-то происходит — объект изменяется, срабатывает расписание или вы запускаете его сами. Этот список показывает потоки всех приложений.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пара ключей VAPID настроена. Пользователи могут включить уведомления браузера в настройках пользователя; они доставляются, даже когда вкладка браузера закрыта.", "A deactivation request is pending.": "Запрос на деактивацию ожидает обработки.", "AI Assistant": "ИИ-помощник", "AI Chat": "ИИ-чат", diff --git a/l10n/sk.js b/l10n/sk.js index 07e6f584dd..c3b84480ae 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} zlyhalo", "0 = unlimited": "0 = neobmedzené", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok spustí sériu krokov, keď sa niečo stane — objekt sa zmení, spustí sa plán, alebo ho spustíte sami. Tento zoznam zobrazuje toky všetkých aplikácií.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Je nakonfigurovaný pár kľúčov VAPID. Používatelia si môžu v osobných nastaveniach zapnúť oznámenia prehliadača, ktoré sa doručujú aj pri zatvorenej karte prehliadača.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Je nakonfigurovaný pár kľúčov VAPID. Používatelia si môžu v používateľských nastaveniach zapnúť oznámenia prehliadača, ktoré sa doručujú aj pri zatvorenej karte prehliadača.", "A deactivation request is pending.": "Žiadosť o deaktiváciu čaká na vybavenie.", "AI Assistant": "AI asistent", "AI Chat": "AI chat", diff --git a/l10n/sk.json b/l10n/sk.json index 133465b455..e537e1c37b 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} zlyhalo", "0 = unlimited": "0 = neobmedzené", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok spustí sériu krokov, keď sa niečo stane — objekt sa zmení, spustí sa plán, alebo ho spustíte sami. Tento zoznam zobrazuje toky všetkých aplikácií.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Je nakonfigurovaný pár kľúčov VAPID. Používatelia si môžu v osobných nastaveniach zapnúť oznámenia prehliadača, ktoré sa doručujú aj pri zatvorenej karte prehliadača.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Je nakonfigurovaný pár kľúčov VAPID. Používatelia si môžu v používateľských nastaveniach zapnúť oznámenia prehliadača, ktoré sa doručujú aj pri zatvorenej karte prehliadača.", "A deactivation request is pending.": "Žiadosť o deaktiváciu čaká na vybavenie.", "AI Assistant": "AI asistent", "AI Chat": "AI chat", diff --git a/l10n/sl.js b/l10n/sl.js index eb8f7422a5..4f57519c3d 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} neuspešno", "0 = unlimited": "0 = neomejeno", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok izvede vrsto korakov, ko se nekaj zgodi — objekt se spremeni, urnik se sproži ali pa ga zaženete sami. Ta seznam prikazuje tokove vseh aplikacij.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ključev VAPID je nastavljen. Uporabniki lahko v osebnih nastavitvah vklopijo obvestila brskalnika, ki so dostavljena tudi, ko je zavihek brskalnika zaprt.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ključev VAPID je nastavljen. Uporabniki lahko v uporabniških nastavitvah vklopijo obvestila brskalnika, ki so dostavljena tudi, ko je zavihek brskalnika zaprt.", "A deactivation request is pending.": "Zahteva za deaktivacijo je v čakanju.", "AI Assistant": "AI asistent", "AI Chat": "AI klepet", diff --git a/l10n/sl.json b/l10n/sl.json index 3e6a20e70e..e0b8771a4d 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} neuspešno", "0 = unlimited": "0 = neomejeno", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok izvede vrsto korakov, ko se nekaj zgodi — objekt se spremeni, urnik se sproži ali pa ga zaženete sami. Ta seznam prikazuje tokove vseh aplikacij.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ključev VAPID je nastavljen. Uporabniki lahko v osebnih nastavitvah vklopijo obvestila brskalnika, ki so dostavljena tudi, ko je zavihek brskalnika zaprt.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ključev VAPID je nastavljen. Uporabniki lahko v uporabniških nastavitvah vklopijo obvestila brskalnika, ki so dostavljena tudi, ko je zavihek brskalnika zaprt.", "A deactivation request is pending.": "Zahteva za deaktivacijo je v čakanju.", "AI Assistant": "AI asistent", "AI Chat": "AI klepet", diff --git a/l10n/sq.js b/l10n/sq.js index 549980838a..875113b161 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} dështoi", "0 = unlimited": "0 = pa limit", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Një rrjedhë ekzekuton një varg hapash kur ndodh diçka — një objekt ndryshon, një planifikim aktivizohet ose e ekzekutoni vetë. Kjo listë shfaq rrjedhat e çdo aplikacioni.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Është konfiguruar një çift çelësash VAPID. Përdoruesit mund të aktivizojnë njoftimet e shfletuesit nga cilësimet e tyre personale dhe ato dorëzohen edhe kur skeda e shfletuesit është e mbyllur.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Është konfiguruar një çift çelësash VAPID. Përdoruesit mund të aktivizojnë njoftimet e shfletuesit nga cilësimet e përdoruesit dhe ato dorëzohen edhe kur skeda e shfletuesit është e mbyllur.", "A deactivation request is pending.": "Një kërkesë çaktivizimi është në pritje.", "AI Assistant": "Asistent AI", "AI Chat": "Chat AI", diff --git a/l10n/sq.json b/l10n/sq.json index 43ddc42c26..3ae9a1a10f 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} dështoi", "0 = unlimited": "0 = pa limit", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Një rrjedhë ekzekuton një varg hapash kur ndodh diçka — një objekt ndryshon, një planifikim aktivizohet ose e ekzekutoni vetë. Kjo listë shfaq rrjedhat e çdo aplikacioni.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Është konfiguruar një çift çelësash VAPID. Përdoruesit mund të aktivizojnë njoftimet e shfletuesit nga cilësimet e tyre personale dhe ato dorëzohen edhe kur skeda e shfletuesit është e mbyllur.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Është konfiguruar një çift çelësash VAPID. Përdoruesit mund të aktivizojnë njoftimet e shfletuesit nga cilësimet e përdoruesit dhe ato dorëzohen edhe kur skeda e shfletuesit është e mbyllur.", "A deactivation request is pending.": "Një kërkesë çaktivizimi është në pritje.", "AI Assistant": "Asistent AI", "AI Chat": "Chat AI", diff --git a/l10n/sr.js b/l10n/sr.js index a6e3b8c40c..de93620eaa 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} neuspješno", "0 = unlimited": "0 = neograničeno", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto desi — objekat se promeni, raspored se aktivira ili ga sami pokrenete. Ova lista prikazuje tokove svih aplikacija.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je podešen. Korisnici mogu u ličnim podešavanjima da uključe obaveštenja pregledača, koja se isporučuju čak i kada je kartica pregledača zatvorena.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je podešen. Korisnici mogu u korisničkim podešavanjima da uključe obaveštenja pregledača, koja se isporučuju čak i kada je kartica pregledača zatvorena.", "A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.", "AI Assistant": "AI asistent", "AI Chat": "AI razgovor", diff --git a/l10n/sr.json b/l10n/sr.json index f57c81b950..31c2bd4487 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} neuspješno", "0 = unlimited": "0 = neograničeno", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto desi — objekat se promeni, raspored se aktivira ili ga sami pokrenete. Ova lista prikazuje tokove svih aplikacija.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je podešen. Korisnici mogu u ličnim podešavanjima da uključe obaveštenja pregledača, koja se isporučuju čak i kada je kartica pregledača zatvorena.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je podešen. Korisnici mogu u korisničkim podešavanjima da uključe obaveštenja pregledača, koja se isporučuju čak i kada je kartica pregledača zatvorena.", "A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.", "AI Assistant": "AI asistent", "AI Chat": "AI razgovor", diff --git a/l10n/sv.js b/l10n/sv.js index 3bbb89ffac..115b703a74 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} misslyckades", "0 = unlimited": "0 = obegränsat", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ett flöde kör en serie steg när något händer — ett objekt ändras, ett schema utlöses eller du kör det själv. Den här listan visar alla appars flöden.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Ett VAPID-nyckelpar är konfigurerat. Användare kan aktivera webbläsaraviseringar i sina användarinställningar, och de levereras även när webbläsarfliken är stängd.", "A deactivation request is pending.": "En avaktiveringsförfrågan väntar.", "AI Assistant": "AI-assistent", "AI Chat": "AI-chatt", diff --git a/l10n/sv.json b/l10n/sv.json index 782a24d55a..0ec0480896 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} misslyckades", "0 = unlimited": "0 = obegränsat", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ett flöde kör en serie steg när något händer — ett objekt ändras, ett schema utlöses eller du kör det själv. Den här listan visar alla appars flöden.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Ett VAPID-nyckelpar är konfigurerat. Användare kan aktivera webbläsaraviseringar i sina användarinställningar, och de levereras även när webbläsarfliken är stängd.", "A deactivation request is pending.": "En avaktiveringsförfrågan väntar.", "AI Assistant": "AI-assistent", "AI Chat": "AI-chatt", diff --git a/l10n/tr.js b/l10n/tr.js index 52936493a3..627cb0a106 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} başarısız", "0 = unlimited": "0 = sınırsız", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Bir akış, bir şey olduğunda — bir nesne değiştiğinde, bir zamanlama tetiklendiğinde veya siz kendiniz çalıştırdığınızda — bir dizi adımı çalıştırır. Bu liste her uygulamanın akışlarını gösterir.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Bir VAPID anahtar çifti yapılandırılmış. Kullanıcılar tarayıcı bildirimlerini kişisel ayarlarından açabilir; bu bildirimler tarayıcı sekmesi kapalıyken bile iletilir.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Bir VAPID anahtar çifti yapılandırılmış. Kullanıcılar tarayıcı bildirimlerini kullanıcı ayarlarından açabilir; bu bildirimler tarayıcı sekmesi kapalıyken bile iletilir.", "A deactivation request is pending.": "Bir devre dışı bırakma isteği beklemede.", "AI Assistant": "Yapay Zeka Asistanı", "AI Chat": "Yapay Zeka Sohbeti", diff --git a/l10n/tr.json b/l10n/tr.json index 65513ec886..ee6bd6ec13 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} başarısız", "0 = unlimited": "0 = sınırsız", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Bir akış, bir şey olduğunda — bir nesne değiştiğinde, bir zamanlama tetiklendiğinde veya siz kendiniz çalıştırdığınızda — bir dizi adımı çalıştırır. Bu liste her uygulamanın akışlarını gösterir.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Bir VAPID anahtar çifti yapılandırılmış. Kullanıcılar tarayıcı bildirimlerini kişisel ayarlarından açabilir; bu bildirimler tarayıcı sekmesi kapalıyken bile iletilir.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Bir VAPID anahtar çifti yapılandırılmış. Kullanıcılar tarayıcı bildirimlerini kullanıcı ayarlarından açabilir; bu bildirimler tarayıcı sekmesi kapalıyken bile iletilir.", "A deactivation request is pending.": "Bir devre dışı bırakma isteği beklemede.", "AI Assistant": "Yapay Zeka Asistanı", "AI Chat": "Yapay Zeka Sohbeti", diff --git a/l10n/uk.js b/l10n/uk.js index 01e6aeede1..075118dac8 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -56,7 +56,7 @@ OC.L10N.register( ", {failed} failed": ", {failed} неудачно", "0 = unlimited": "0 = неограниченно", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Потік запускає серію кроків, коли щось відбувається — об'єкт змінюється, спрацьовує розклад або ви запускаєте його самі. Цей список показує потоки всіх застосунків.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пару ключів VAPID налаштовано. Користувачі можуть увімкнути сповіщення браузера в налаштуваннях користувача; їх доставляють, навіть коли вкладка браузера закрита.", "A deactivation request is pending.": "Запит на деактивацію ожидает обробки.", "AI Assistant": "ИИ-помощник", "AI Chat": "ИИ-чат", diff --git a/l10n/uk.json b/l10n/uk.json index a7f76d59c6..59498da918 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -43,7 +43,7 @@ ", {failed} failed": ", {failed} неудачно", "0 = unlimited": "0 = неограниченно", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Потік запускає серію кроків, коли щось відбувається — об'єкт змінюється, спрацьовує розклад або ви запускаєте його самі. Цей список показує потоки всіх застосунків.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пару ключів VAPID налаштовано. Користувачі можуть увімкнути сповіщення браузера в налаштуваннях користувача; їх доставляють, навіть коли вкладка браузера закрита.", "A deactivation request is pending.": "Запит на деактивацію ожидает обробки.", "AI Assistant": "ИИ-помощник", "AI Chat": "ИИ-чат", diff --git a/scripts/l10n/locales/ca.json b/scripts/l10n/locales/ca.json index aefc9c8ab3..2b44d302bc 100644 --- a/scripts/l10n/locales/ca.json +++ b/scripts/l10n/locales/ca.json @@ -167,7 +167,7 @@ "OpenAI Chat Settings": "TERM-SETTINGS", "Could not change browser notification settings.": "TERM-SETTINGS", "Notification permission: denied. Re-enable notifications for this site in your browser settings.": "TERM-SETTINGS", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "TERM-SETTINGS", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "TERM-SETTINGS", "Manage your account settings, security, and personal data.": "TERM-SETTINGS", "Any favorites and sharing settings for this view": "TERM-SETTINGS", "They will be retained according to their schema's configured retention period and automatically permanently deleted when the retention period expires. The retention period is configurable per schema and can be found in the schema's settings.": "TERM-SETTINGS", diff --git a/scripts/l10n/locales/sr.json b/scripts/l10n/locales/sr.json index 3dd8ac4752..a1b7e5dcc4 100644 --- a/scripts/l10n/locales/sr.json +++ b/scripts/l10n/locales/sr.json @@ -96,7 +96,7 @@ "%n register is missing": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", "%n registers are missing": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", "Access (Art 15)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", "Access results": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", "Accountability": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", diff --git a/scripts/l10n/locales/sv.json b/scripts/l10n/locales/sv.json index 4a5f43b441..26e6a44332 100644 --- a/scripts/l10n/locales/sv.json +++ b/scripts/l10n/locales/sv.json @@ -4,7 +4,6 @@ "cognates": { "3": "Numeric literal; identical in every locale.", "30": "Numeric literal; identical in every locale.", - "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Technical token / API or config example; not translatable prose.", "App": "Proper noun / product or brand name; kept as-is in Swedish.", "Attribute": "Proper noun / product or brand name; kept as-is in Swedish.", "Available": "Proper noun / product or brand name; kept as-is in Swedish.", From 6d71d17cd1ff455a54db05a3a1f57ff4c99e353e Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Fri, 16 Oct 2026 11:09:06 +0200 Subject: [PATCH 35/37] fix(quality): phpcs line length and phpmd findings in the review fixes Wrap the five lines over 150 characters, import-alias the DoesNotExistException the draft-delete guard throws, and bring the phpmd findings of the review fixes under their thresholds: RelationDecisionService::decide() and FrozenNodeWriteListener::refusalFor() lose their ownership and owner-lookup steps to private helpers, EntityRelationMapper::linkDecision() and ShippedConfigurationGuard lose their else branches and logging block to helpers. Suppressions with a reason only where the repo does the same for comparable code (DI constructor parameter count, class complexity, method count, coupling, a flag naming where a walk starts). Co-Authored-By: Claude Opus 5.5 --- lib/Db/EntityRelationMapper.php | 33 ++++++-- lib/Db/MagicMapper/MagicRbacHandler.php | 3 + lib/Listener/FrozenNodeWriteListener.php | 28 ++++++- lib/Service/Case/CasePlanItemTimers.php | 11 ++- .../Decision/RelationDecisionService.php | 56 +++++++++----- .../Deferral/ListenerDeferralService.php | 4 +- lib/Service/File/DeleteFileHandler.php | 3 +- lib/Service/Form/FormSubmitService.php | 2 + lib/Service/ObjectService.php | 5 +- .../ShippedConfigurationGuard.php | 76 +++++++++++++------ 10 files changed, 166 insertions(+), 55 deletions(-) diff --git a/lib/Db/EntityRelationMapper.php b/lib/Db/EntityRelationMapper.php index 761d6e8b81..17e3a0d613 100644 --- a/lib/Db/EntityRelationMapper.php +++ b/lib/Db/EntityRelationMapper.php @@ -26,6 +26,7 @@ use OCA\OpenRegister\Exception\DecisionConflictException; use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\ICompositeExpression; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; @@ -963,18 +964,22 @@ private function emitDecisionMetadataAuditEntry( * @throws DecisionConflictException When guarded and the stored link changed in the meantime. * * @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001 + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The default keeps every unguarded caller unchanged; the guard is one + * extra statement, not a second behaviour. */ - public function linkDecision(EntityRelation $relation, string $decisionUuid, ?string $expectedUuid = null, bool $guarded = false): EntityRelation { + public function linkDecision( + EntityRelation $relation, + string $decisionUuid, + ?string $expectedUuid = null, + bool $guarded = false + ): EntityRelation { if ($guarded === true) { $qb = $this->db->getQueryBuilder(); $qb->update($this->getTableName()) ->set('decision_uuid', $qb->createNamedParameter($decisionUuid)) ->where($qb->expr()->eq('id', $qb->createNamedParameter($relation->getId(), IQueryBuilder::PARAM_INT))); - if ($expectedUuid === null || $expectedUuid === '') { - $qb->andWhere($qb->expr()->orX($qb->expr()->isNull('decision_uuid'), $qb->expr()->eq('decision_uuid', $qb->createNamedParameter('')))); - } else { - $qb->andWhere($qb->expr()->eq('decision_uuid', $qb->createNamedParameter($expectedUuid))); - } + $qb->andWhere($this->storedLinkIs(qb: $qb, expectedUuid: $expectedUuid)); if ($qb->executeStatement() === 0) { throw new DecisionConflictException(message: 'The relation was decided by somebody else in the meantime.'); @@ -985,4 +990,20 @@ public function linkDecision(EntityRelation $relation, string $decisionUuid, ?st return $this->update(entity: $relation); }//end linkDecision() + + /** + * The condition "the stored decision link is still what the caller read". + * + * @param IQueryBuilder $qb The query builder the condition is for. + * @param string|null $expectedUuid The link the caller read; null or empty means none. + * + * @return ICompositeExpression|string The condition for `andWhere()`. + */ + private function storedLinkIs(IQueryBuilder $qb, ?string $expectedUuid): ICompositeExpression|string { + if ($expectedUuid !== null && $expectedUuid !== '') { + return $qb->expr()->eq('decision_uuid', $qb->createNamedParameter($expectedUuid)); + } + + return $qb->expr()->orX($qb->expr()->isNull('decision_uuid'), $qb->expr()->eq('decision_uuid', $qb->createNamedParameter(''))); + }//end storedLinkIs() }//end class diff --git a/lib/Db/MagicMapper/MagicRbacHandler.php b/lib/Db/MagicMapper/MagicRbacHandler.php index f94304ebc7..175e8b2e54 100644 --- a/lib/Db/MagicMapper/MagicRbacHandler.php +++ b/lib/Db/MagicMapper/MagicRbacHandler.php @@ -131,6 +131,9 @@ class MagicRbacHandler { * construction sites. * @param DenyEnforcementMode|null $denyEnforcementMode The staging switch; nullable for the same reason. * @param MagicMetadataColumnHealer|null $metadataColumns Knows which metadata columns a table that could not be healed still lacks. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI requires constructor injection; the optional + * collaborators are nullable so existing construction sites keep working. */ public function __construct( private readonly IUserSession $userSession, diff --git a/lib/Listener/FrozenNodeWriteListener.php b/lib/Listener/FrozenNodeWriteListener.php index ecf2034819..6e3ef1122d 100644 --- a/lib/Listener/FrozenNodeWriteListener.php +++ b/lib/Listener/FrozenNodeWriteListener.php @@ -206,6 +206,28 @@ private function refusalFor(Node $node): ?ObjectStateWriteException { return null; } + $object = $this->owningObjectOf(node: $node, path: $path, candidate: $candidate); + if ($object instanceof ObjectStateWriteException) { + return $object; + } + + if ($object === null) { + return null; + } + + return $this->guard->refusalFor(object: $object); + }//end refusalFor() + + /** + * The object that owns a file's folder, or a refusal when it cannot be resolved. + * + * @param Node $node The node about to change. + * @param string $path The node's path, for the log. + * @param array{uuid: string, folderId: int|string} $candidate The ancestor folder named like a UUID. + * + * @return ObjectEntity|ObjectStateWriteException|null The owning object; the refusal when the lookup failed; null when the folder owns nothing. + */ + private function owningObjectOf(Node $node, string $path, array $candidate): ObjectEntity|ObjectStateWriteException|null { try { $found = $this->objects->findAcrossAllSources( identifier: $candidate['uuid'], @@ -234,8 +256,8 @@ private function refusalFor(Node $node): ?ObjectStateWriteException { return null; } - return $this->guard->refusalFor(object: $object); - }//end refusalFor() + return $object; + }//end owningObjectOf() /** * The node in the `openregister` home that a node outside it stands for, found by file id. @@ -297,6 +319,8 @@ private function homeNodeOf(Node $node, string $home): ?array { * @param bool $includeSelf Whether the node itself may be the owner folder (a node standing for a not-yet-existing child). * * @return array{uuid: string, folderId: int|string}|null The candidate, or null when no ancestor qualifies. + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The flag names where the walk starts (the node or its parent), not a second behaviour. */ private function candidateOwnerFolder(Node $node, bool $includeSelf = false): ?array { $current = $node; diff --git a/lib/Service/Case/CasePlanItemTimers.php b/lib/Service/Case/CasePlanItemTimers.php index 0542899c0f..7fc7534864 100644 --- a/lib/Service/Case/CasePlanItemTimers.php +++ b/lib/Service/Case/CasePlanItemTimers.php @@ -222,14 +222,21 @@ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadlin // active without the timer that enforces its statutory expiry. $this->logger->error( sprintf( - '[CasePlanItemTimers] Plan item %s of case object %s is active WITHOUT its statutory (%s) %s timer: the term will not run and nothing will end the item. %s', + '[CasePlanItemTimers] Plan item %s of case object %s is active WITHOUT its statutory (%s) %s timer: ' + . 'the term will not run and nothing will end the item. %s', (string)$item->getItemKey(), (string)$item->getObjectUuid(), FlowTimer::LEGAL_WETTELIJK, $purpose, $failure->getMessage() ), - ['item' => $item->getUuid(), 'case' => $item->getObjectUuid(), 'legalEffect' => FlowTimer::LEGAL_WETTELIJK, 'purpose' => $purpose, 'exception' => $failure] + [ + 'item' => $item->getUuid(), + 'case' => $item->getObjectUuid(), + 'legalEffect' => FlowTimer::LEGAL_WETTELIJK, + 'purpose' => $purpose, + 'exception' => $failure, + ] ); return false; diff --git a/lib/Service/Decision/RelationDecisionService.php b/lib/Service/Decision/RelationDecisionService.php index 8ca3b3f8ad..67c59137ca 100644 --- a/lib/Service/Decision/RelationDecisionService.php +++ b/lib/Service/Decision/RelationDecisionService.php @@ -41,6 +41,9 @@ * Applies a decision to a relation and records it as a decision object. * * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001 + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The decision flow meets the relation, its mapper, the decision + * store, the policies and the ownership check; the conflict exception is the compare-and-swap's one extra collaborator. */ class RelationDecisionService { @@ -105,23 +108,7 @@ public function decide(EntityRelation $relation, array $body, IUser $actor): arr $actorId = $actor->getUID(); $linkedUuid = $relation->getDecisionUuid(); $current = $this->decisions->find(uuid: $linkedUuid); - $owned = $current; - if ($owned === null && $linkedUuid !== null && $linkedUuid !== '') { - // The relation names a decision whose record cannot be found. - // DecisionVisibility hides that state; here it belongs to nobody - // but a supervisor, so a reviewer cannot take it over by the gap. - $owned = new Decision(); - $owned->setUuid($linkedUuid); - $owned->setDecidedBy(''); - } - - if ($owned !== null) { - $resolved = $this->policies->forRelation(relation: $relation); - $refusal = $this->ownership->refusal(decision: $owned, actorId: $actorId, policy: $resolved['policy'], subject: $resolved['subject']); - if ($refusal !== null) { - throw new DecisionOwnedException(refusal: $refusal); - } - } + $this->assertMayChange(relation: $relation, current: $current, linkedUuid: $linkedUuid, actorId: $actorId); $before = [$relation->getBases(), $relation->getSkipAnonymization()]; @@ -173,6 +160,41 @@ public function decide(EntityRelation $relation, array $body, IUser $actor): arr return ['relation' => $updated, 'decision' => $decision]; }//end decide() + /** + * Refuse a write on a decision the actor does not own. + * + * A relation that names a decision whose record cannot be found belongs to + * nobody but a supervisor: DecisionVisibility hides that state, and a + * reviewer must not take it over through the gap. + * + * @param EntityRelation $relation The relation. + * @param Decision|null $current The decision record the relation links, when it exists. + * @param string|null $linkedUuid The decision uuid the relation names. + * @param string $actorId The reviewer. + * + * @return void + * + * @throws DecisionOwnedException When the decision belongs to someone else. + */ + private function assertMayChange(EntityRelation $relation, ?Decision $current, ?string $linkedUuid, string $actorId): void { + $owned = $current; + if ($owned === null && $linkedUuid !== null && $linkedUuid !== '') { + $owned = new Decision(); + $owned->setUuid($linkedUuid); + $owned->setDecidedBy(''); + } + + if ($owned === null) { + return; + } + + $resolved = $this->policies->forRelation(relation: $relation); + $refusal = $this->ownership->refusal(decision: $owned, actorId: $actorId, policy: $resolved['policy'], subject: $resolved['subject']); + if ($refusal !== null) { + throw new DecisionOwnedException(refusal: $refusal); + } + }//end assertMayChange() + /** * Whether this write is a decision worth recording. * diff --git a/lib/Service/Deferral/ListenerDeferralService.php b/lib/Service/Deferral/ListenerDeferralService.php index ea96b09ba8..699b774983 100644 --- a/lib/Service/Deferral/ListenerDeferralService.php +++ b/lib/Service/Deferral/ListenerDeferralService.php @@ -363,7 +363,9 @@ private function chunksUnderLimit(string $jobClass, array $entries): array { $reducedLength = (strlen((string)json_encode($reduced)) + 1); if ($reducedLength <= $budget) { $this->logger->warning( - message: '[ListenerDeferralService] A deferred listener entry was too large for a background job; it was enqueued without its previous data; conditioned updated rules and calculatedChange rules are skipped for this object', + message: '[ListenerDeferralService] A deferred listener entry was too large for a background job; ' + . 'it was enqueued without its previous data; conditioned updated rules and ' + . 'calculatedChange rules are skipped for this object', context: [ 'file' => __FILE__, 'line' => __LINE__, diff --git a/lib/Service/File/DeleteFileHandler.php b/lib/Service/File/DeleteFileHandler.php index abb85e0c50..12b499fc31 100644 --- a/lib/Service/File/DeleteFileHandler.php +++ b/lib/Service/File/DeleteFileHandler.php @@ -143,7 +143,8 @@ public function deleteFile(Node|string|int $file, ?ObjectEntity $object = null): // is still there. Saying true here makes every caller (the example // set remover, the merge) believe a file is gone that is not. $this->logger->warning( - message: '[DeleteFileHandler] The file was not deleted: it is still there after delete() (an operation was aborted, for instance by a frozen object).', + message: '[DeleteFileHandler] The file was not deleted: it is still there after delete() ' + . '(an operation was aborted, for instance by a frozen object).', context: ['file' => __FILE__, 'line' => __LINE__, 'fileId' => $fileId] ); return false; diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php index 63965d9c07..e1504614c2 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -71,6 +71,8 @@ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The complexity is the submit contract: * map, validate all, write in order, compensate, answer. Split, the all-or-none rule would * live in two files that must agree on what a plan is. + * @SuppressWarnings(PHPMD.TooManyMethods) One private method per step of the submit (map, validate, + * write, compensate, answer, idempotency, uploads); the steps are the contract, not incidental helpers. * @SuppressWarnings(PHPMD.StaticAccess) FormDestinationValidator::marker() and * markedProperties() are pure reads of a schema property's `x-openregister` block; * DraftStatusPolicy's promotion allowance is request-scoped state shared with the save path. diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index b09fe96135..854d40c736 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -3221,10 +3221,11 @@ public function deleteObject( $deleterIsAdmin = ($deleterUser !== null && in_array('admin', $this->groupManager->getUserGroupIds($deleterUser), true) === true); // An administrator reads every draft, so is not kept from one. + $deleterUid = $deleterUser?->getUID(); if ($deleterIsAdmin === false - && (new DraftStatusPolicy())->keptFrom(object: $objectToDelete, schema: $this->currentSchema, userId: $deleterUser?->getUID()) === true + && (new DraftStatusPolicy())->keptFrom(object: $objectToDelete, schema: $this->currentSchema, userId: $deleterUid) === true ) { - throw new \OCP\AppFramework\Db\DoesNotExistException('Object not found'); + throw new OcpDoesNotExistException('Object not found'); } } diff --git a/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php b/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php index 71256c8156..a165717004 100644 --- a/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php +++ b/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php @@ -52,6 +52,9 @@ /** * Guards an app-shipped schema update against local changes. * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One guard for every shipped definition kind: baseline read, merge, + * audit of each authorization change and the fail-closed fallback share one definition of what "shipped" means. + * * @spec openspec/changes/local-changes-to-app-shipped-configuration/specs/schema-import/spec.md */ class ShippedConfigurationGuard { @@ -163,7 +166,12 @@ public function guardSchemaUpdate( decisions: $decisions ); $this->recordReplaced(slug: $slug, app: $app, roots: $result['replacedLocal']); - $this->recordAppliedAuthorization(slug: $slug, app: $app, applied: $result['applied'], recorded: array_merge($result['replacedLocal'], $decisions)); + $this->recordAppliedAuthorization( + slug: $slug, + app: $app, + applied: $result['applied'], + recorded: array_merge($result['replacedLocal'], $decisions) + ); if ($record === true) { $this->baselines->record( @@ -174,18 +182,7 @@ public function guardSchemaUpdate( ); } - if ($result['conflicts'] !== []) { - // INFO on the parts that were left alone, because this is the - // decision somebody re-reads the upgrade log to find. The - // upgrade itself completes either way. - $this->logger->info( - sprintf( - '[ShippedConfigurationGuard] %s: %d part(s) changed on both sides, kept local and reported', - $slug, - count($result['conflicts']) - ) - ); - } + $this->logKeptLocal(slug: $slug, conflicts: $result['conflicts']); foreach ($decisions as $path) { $this->recordDecision(slug: $slug, path: (string)$path, app: $app); @@ -220,6 +217,27 @@ public function guardSchemaUpdate( }//end try }//end guardSchemaUpdate() + /** + * Log, at INFO, the parts that changed on both sides and were left alone. + * + * INFO, because this is the decision somebody re-reads the upgrade log to + * find. The upgrade itself completes either way. + * + * @param string $slug The schema slug. + * @param array> $conflicts The parts kept local. + * + * @return void + */ + private function logKeptLocal(string $slug, array $conflicts): void { + if ($conflicts === []) { + return; + } + + $this->logger->info( + sprintf('[ShippedConfigurationGuard] %s: %d part(s) changed on both sides, kept local and reported', $slug, count($conflicts)) + ); + }//end logKeptLocal() + /** * The incoming definition with the instance's own authorization blocks put back. * @@ -234,12 +252,7 @@ public function guardSchemaUpdate( * @return array The definition to write. */ private function keepingLiveAuthorization(array $live, array $incoming): array { - $definition = $incoming; - if (array_key_exists('authorization', $live) === true) { - $definition['authorization'] = $live['authorization']; - } else { - unset($definition['authorization']); - } + $definition = $this->withAuthorizationOf(target: $incoming, live: $live); $liveProperties = ($live['properties'] ?? null); if (is_array($liveProperties) === false || is_array($definition['properties'] ?? null) === false) { @@ -251,16 +264,31 @@ private function keepingLiveAuthorization(array $live, array $incoming): array { continue; } - if (array_key_exists('authorization', $liveProperties[$name]) === true) { - $definition['properties'][$name]['authorization'] = $liveProperties[$name]['authorization']; - } else { - unset($definition['properties'][$name]['authorization']); - } + $definition['properties'][$name] = $this->withAuthorizationOf(target: $property, live: $liveProperties[$name]); } return $definition; }//end keepingLiveAuthorization() + /** + * A definition part carrying the `authorization` block of the instance's own part, or none when the instance has none. + * + * @param array $target The part from the incoming definition. + * @param array $live The same part as the instance has it. + * + * @return array The part with the instance's authorization. + */ + private function withAuthorizationOf(array $target, array $live): array { + if (array_key_exists('authorization', $live) === false) { + unset($target['authorization']); + return $target; + } + + $target['authorization'] = $live['authorization']; + + return $target; + }//end withAuthorizationOf() + /** * Record, on the audit trail, the authorization blocks an upgrade took from the app because only the app changed them. * From ca4dddafe450bc2fc3ebebf36e671b28cdbcbd45 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Fri, 16 Oct 2026 11:09:06 +0200 Subject: [PATCH 36/37] test(aggregation): give the SQLite fixtures the _status column the draft lens names The native aggregation path now adds the draft clause (data-f5), which names _status; real magic tables carry that column, the hand-made SQLite fixtures of four native-path tests did not, so the native query failed and every one fell back to the PHP path. Co-Authored-By: Claude Opus 5.5 --- .../Aggregation/AggregationJoinAndCompositeGroupByTest.php | 4 ++-- .../Aggregation/AggregationRunnerMultiFieldGroupByTest.php | 2 +- .../Service/Aggregation/AggregationRunnerMultiMetricTest.php | 2 +- .../Aggregation/AggregationRunnerMultiValueFilterTest.php | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php b/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php index 1cf31e676c..d5880113f3 100644 --- a/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php +++ b/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php @@ -1585,7 +1585,7 @@ private function seedSqlite(): PDO { $pdo->exec( 'CREATE TABLE "oc_register_1_commitment_line" ( "_deleted" TEXT, - "_archived" TEXT, + "_archived" TEXT, "_status" TEXT, "_organisation" TEXT, "programme" TEXT, "cost_centre" TEXT, @@ -1595,7 +1595,7 @@ private function seedSqlite(): PDO { $pdo->exec( 'CREATE TABLE "oc_register_1_commitment_budget" ( "_deleted" TEXT, - "_archived" TEXT, + "_archived" TEXT, "_status" TEXT, "_organisation" TEXT, "programme_code" TEXT, "authorised_amount" INTEGER diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php index 12f9eb7989..13ef39ee53 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php @@ -317,7 +317,7 @@ private function seedSqlite(): PDO { $pdo->exec( 'CREATE TABLE "oc_register_1_schema_ap_tx" ( "_deleted" TEXT, - "_archived" TEXT, + "_archived" TEXT, "_status" TEXT, "_organisation" TEXT, "vendor_id" TEXT, "due_date_bucket" TEXT, diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php index 3751f244d6..0a195211a1 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php @@ -325,7 +325,7 @@ public function testOneElementMetricsListBehavesLikeLegacySingleMetric(): void { private function makeNativeRunner(): AggregationRunner { $pdo = new PDO('sqlite::memory:'); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_organisation" TEXT, "status" TEXT, "amount" INTEGER)'); + $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_status" TEXT, "_organisation" TEXT, "status" TEXT, "amount" INTEGER)'); $insert = $pdo->prepare('INSERT INTO "oc_register_1_schema_x" ("_deleted", "_organisation", "status", "amount") VALUES (NULL, ?, ?, ?)'); foreach ($this->dataset() as $row) { $insert->execute(['__no_active_org__', $row['status'], $row['amount']]); diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php index a90303e60e..325a26491a 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php @@ -451,13 +451,13 @@ private function makeNativeRunner(array $dataset, mixed &$capturedSql, ?Schema $ $isTags = ($schema !== null && $schema->getSlug() === 'tagged-items'); if ($isTags === true) { - $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_organisation" TEXT, "id" INTEGER, "tags" TEXT)'); + $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_status" TEXT, "_organisation" TEXT, "id" INTEGER, "tags" TEXT)'); $insert = $pdo->prepare('INSERT INTO "oc_register_1_schema_x" ("_deleted", "_organisation", "id", "tags") VALUES (NULL, ?, ?, ?)'); foreach ($dataset as $row) { $insert->execute(['__no_active_org__', $row['id'], json_encode($row['tags'])]); } } else { - $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_organisation" TEXT, "status" TEXT, "region" TEXT, "amount" INTEGER)'); + $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_status" TEXT, "_organisation" TEXT, "status" TEXT, "region" TEXT, "amount" INTEGER)'); $insert = $pdo->prepare('INSERT INTO "oc_register_1_schema_x" ("_deleted", "_organisation", "status", "region", "amount") VALUES (NULL, ?, ?, ?, ?)'); foreach ($dataset as $row) { $insert->execute(['__no_active_org__', $row['status'], ($row['region'] ?? null), $row['amount']]); From db13f88c035d673c795e826ba50b53b54bce621c Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Fri, 16 Oct 2026 11:09:06 +0200 Subject: [PATCH 37/37] fix(spec): follow the renamed oauth2-connect requirement in its @spec anchors The requirement heading now ends 'from user settings' (surf-f4); the @spec tags in OAuth2ConnectionsSection.vue and the e2e spec still pointed at the personal-settings slug, which gate 46 reports as 13 unresolved anchors. Co-Authored-By: Claude Opus 5.5 --- .../userSettings/OAuth2ConnectionsSection.vue | 24 +++++++++---------- tests/e2e/credential-oauth2-connect.spec.ts | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/userSettings/OAuth2ConnectionsSection.vue b/src/components/userSettings/OAuth2ConnectionsSection.vue index a143986901..a04b847804 100644 --- a/src/components/userSettings/OAuth2ConnectionsSection.vue +++ b/src/components/userSettings/OAuth2ConnectionsSection.vue @@ -15,7 +15,7 @@ browser at all: the whole point of the broker is that the secret stays server-side and OpenRegister makes the outbound call. - @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings + @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings -->