diff --git a/CHANGELOG.md b/CHANGELOG.md index 2506817ed1..a083cb2f71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- **New API surface in this release.** `POST /api/forms/{formId}/submit`, `.../uploads` and `.../draft` and `POST /api/forms/validate` (forms, #4563); `POST /api/objects/{register}/{schema}/{id}/activate` (make a draft definitive, #4600); `GET /api/objects/{register}/{schema}/{id}/tree` (the tree along a schema's parent property, #4656); `/api/record-templates` (CRUD, #4595); `POST /api/migration-packs/preview` (#4602); and the occ commands `openregister:example-set:list`, `:install` and `:remove` (#4603). Each is described in its openspec change. - **Schema-scoped Smart Picker / unified-search provider base classes (`schema-scoped-smart-picker`).** Two new reusable abstract classes let a consuming app expose one (register, schema) pair as its own discoverable entry in Nextcloud's Smart Picker "Select provider" list, instead of every object funnelling through the single generic "Register Objects" entry: `AbstractSchemaReferenceProvider` (`lib/AppHost/Reference/AbstractSchemaReferenceProvider.php`, extends `ADiscoverableReferenceProvider` + `ISearchableReferenceProvider`) and `AbstractSchemaSearchProvider` (`lib/AppHost/Search/AbstractSchemaSearchProvider.php`, implements `OCP\Search\IProvider`). A subclass implements only `getRegisterSlug()`/`getSchemaSlug()` — the provider id (`openregister-ref-{registerSlug}-{schemaSlug}` / `openregister_objects_{registerSlug}_{schemaSlug}`), title, and icon are computed by `final` base-class methods from the schema's own live metadata, never chosen by the app, so ids can never collide. `matchReference()`/`resolveReference()` reuse the existing URL-parsing and rich-preview logic via the new shared `ObjectPreviewFormatter` (`lib/Service/Reference/ObjectPreviewFormatter.php`) and reject any URL outside the configured schema; `search()` delegates to `ObjectService::searchObjectsPaginated(_rbac: true, _multitenancy: true)` scoped to the configured schema via the new shared `ObjectSearchResultFormatter` (`lib/Service/Search/ObjectSearchResultFormatter.php`) — both existing generic providers (`ObjectsProvider`/`ObjectReferenceProvider`) were refactored to delegate to these same shared services, behavior-preserving, same provider ids. A new `Schema::$smartPickerEnabled` flag (default `false`, migration `Version1Date20260817120000`, `SchemaMapper::findSmartPickerEnabledIds()`, mirroring the existing `searchable` flag) gates whether a schema-scoped provider is functionally active — disabling it makes `matchReference()`/`resolveReference()`/`search()` return nothing, but cannot remove the provider's entry from the picker list, since Nextcloud resolves that list from boot-time class registration, not a runtime flag. Also closes two previously-documented-but-unimplemented `mail-smart-picker` gaps: `ObjectService::saveObject()` now invalidates the Smart Picker reference cache for every canonical URL shape via `IReferenceManager::invalidateCache()`, and `ObjectReferenceProvider` now implements `OCP\Collaboration\Reference\IPublicReferenceProvider` so publicly-readable objects get rich previews for anonymous viewers. No consuming-app subclass ships in this change — Pipelinq's `LeadReferenceProvider`/`LeadSearchProvider` is a follow-up. (`schema-scoped-smart-picker`) - **Context Chat content provider — register objects indexed into the Nextcloud Assistant RAG pipeline (`context-chat-provider`).** OpenRegister now implements `OCP\ContextChat\IContentProvider` (`lib/ContextChat/ContentProvider.php`), registered via `ContentProviderRegisterEvent` (`lib/ContextChat/ContentProviderRegistrationListener.php`) and softly gated on `IContentManager::isContextChatAvailable()` — a standalone instance without the `context_chat` app installed is entirely unaffected (`OCP\ContextChat\*` are core NC 32+ interfaces that always resolve via DI; only the actual submission/registration calls no-op when the app is absent). A new per-schema opt-in, `configuration['x-openregister-contextchat']` (default OFF), added to `Schema::setConfiguration()`'s annotation allow-list plus an `isContextChatIndexingEnabled()` accessor, gates which schemas' objects are ever submitted. `lib/Listener/ContextChatSubmissionListener.php` submits/removes content on the existing `ObjectCreatedEvent`/`ObjectUpdatedEvent`/`ObjectDeletedEvent` listeners (fail-soft — a Context Chat outage never blocks an object write); the publication predicate is implemented as "the `public` group is granted `read` under the schema's resolved authorization" — the living RBAC-`$now` equivalent of the removed `@self.published`/`@self.depublished` metadata (see `deprecate-published-metadata`). `getItemUrl()` reuses `DeepLinkRegistryService::resolveUrl()` with the `openregister.objects.show` fallback, the same mechanism `ObjectsProvider` already uses for unified search. New `occ openregister:contextchat:reindex` command (optional `--register`/`--schema` scoping) and `ContentProvider::triggerInitialImport()` share one batched backfill path (`lib/Command/ContextChatReindexCommand.php`). (`context-chat-provider`) - **Field-level object encryption at rest (`field-level-encryption`).** Schema property flag `x-openregister-encrypted: true` encrypts flagged object field values at rest, transparently, for every app built on OpenRegister — reusing `OCP\Security\ICrypto` (the same primitive `TenantKeyService`/`SourcesController`/`WorkflowEngineRegistry` already use for credentials, no new crypto). New `lib/Service/FieldEncryptionHandler.php` encrypts on save (`SaveObject::prepareObjectData()`, last step before persistence) and decrypts on read (`RenderObject::renderEntity()`/`redactWriteOnlyFromRows()`) — decryption runs strictly *after* the existing `writeOnly`/property-authorization redaction and only touches properties still present, so an unauthorized caller never receives ciphertext or plaintext, only the same absence every other redacted field gets. Stored values use a versioned envelope (`openregister:enc:v1:`) so mixed plaintext/ciphertext during rollout is unambiguous. An encrypted property gets no dedicated magic-table column (`MagicMapper::buildTableColumnsFromSchema()`), is excluded from full-text search and facets, and filtering on it is rejected with HTTP 400 (`EncryptedFieldFilterException` via new `EncryptedFieldFilterMiddleware`) rather than silently returning zero rows. New `occ openregister:encrypt-field --property=` command migrates existing plaintext values idempotently, with `--dry-run`/`--register`/`--schema` scoping. Decryption failure never fails silently — a structured `@openregister_decryption_error` marker + ERROR log by default, or a thrown `FieldDecryptionException` for strict callers (the migration command). No behaviour change for any schema that does not use the new flag. (`field-level-object-encryption`) @@ -37,6 +38,18 @@ - **A draft no longer fires `ObjectCreatedEvent`, `ObjectUpdatedEvent` or `ObjectDeletedEvent` (`draft-creation-side-effects`, decision 227).** A draft is an object in status `draft`. Flows, webhooks, activity, push, metrics and notifications no longer run on a draft's saves or its delete. They run once, when the draft leaves draft: `ObjectCreatedEvent` is dispatched then, followed by `ObjectActivatedEvent`. A listener on `ObjectCreatedEvent` needs no status check. Do creation work on `ObjectCreatedEvent` only. A listener that does it on `ObjectActivatedEvent` too runs twice. Objects that never were a draft behave as before. - **A draft can no longer be changed by somebody who cannot read it (`draft-owner-activation-and-lifetime`).** A draft is visible to its owner only. Until now a second account could still change it: a `PATCH` on the draft, or a `POST` that named its id, was judged by the schema's create rule and stored over the draft. The answer held the draft's content. Both now answer 403. `GET`, `PUT` and `DELETE` already answered 404. An administrator may still correct a draft, and a schema with `draftsVisible: true` keeps its own rules. - **Upgrade note: drafts now expire after 30 days (`draft-owner-activation-and-lifetime`, decisions 216, 226 and 228).** A draft is an object in status `draft`. A draft that nobody changed for 30 days is removed by the daily job `DraftExpiryJob`, for good and with its files. This applies to drafts that exist today: **the first run of the job after the upgrade removes every draft older than 30 days**, at most 1,000 per run. Look before it runs: `occ openregister:drafts:expired` lists how many drafts the next run would remove per schema, and removes nothing. To keep the drafts of a schema, set `"draftLifetimeDays": false` in its configuration. A whole number of days, 1 or more, sets another lifetime. The lifetime counts from the draft's last change. Each removed draft leaves one audit-trail entry (`draft.removed`) that says who or which job removed it, when, in which register and schema, and which object. The entry never holds what the draft contained. The job logs a count per schema. +- **Upgrade note: a roles block that resolves to nothing now grants nothing (`roles-block-that-resolves-to-nothing-grants-nothing`, #4589).** A schema or register `authorization` that carries a `roles` entry opts it into authorization. When none of the roles can be expanded (the register declares no role definitions, or every role name is unknown) the block used to count as "no authorization configured" and every signed-in account could read, create, change and delete. It now grants `read`, `create`, `update` and `delete` to nobody; the owner and administrators still pass, and the generated OAS describes the schema the same way. **This can take access away on upgrade**: before upgrading, check every schema and register that has a `roles` block and make sure each role name is defined in the register's `roles`. A denial names the missing role in the log (`Unknown role name referenced in authorization`). +- **Upgrade note: the files of a frozen or archived object cannot be changed any more, also through Nextcloud Files and WebDAV (`object-archive-state`, REQ-OAS-007, #4549).** Creating, writing, renaming, moving and deleting a file under such an object's folder is refused, as the files API already did: through WebDAV and the Files app (also by a person who reaches the folder through a share), through the object-folder endpoints (`POST/PUT/DELETE .../folder`, `POST .../folder/upload`, which answer 409 with the state, who, when and why) and through the in-process file service, where a delete or move that Nextcloud aborted is reported as failed instead of done. Clients and flows that attach files to archived objects start getting errors; unfreeze or restore the object first. Reading, downloading and previewing are unchanged. +- **Personal settings moved into the in-app user settings dialog (#4587).** OpenRegister no longer shows a section in Nextcloud's personal settings (`OpenRegisterPersonal` and its script are removed). Connected accounts, notifications and the other per-person settings are in the user settings dialog of the OpenRegister app; the "Reconnect your account" notification and the admin hint point there. +- **The audit trail of an object is readable by anyone who may read the object (`audit-trail-immutable`, #4614).** `GET /api/objects/{register}/{schema}/{id}/audit-trails` used to need an administrator; a person who may read the object now reads its history (`GET .../logs` answers non-administrators with the same redacted page), without the session, request and IP address of each entry, without the values of properties that person may not see (property-level rules, write-only properties), without the identity of the object whose delete cascaded into it, and subject to the doelbinding (purpose) rule that the object read runs. This is an exposure change administrators should know about: the history of an object is now as visible as the object. +- **Drafts, forms and tasks: the draft rule now holds on the bulk paths, and a form cannot choose the identity of what it writes.** A draft of somebody else stays hidden from bulk delete, bulk save and the readable audit trail (`/api/audit-trails/readable`), not only from the single-object read and write; a form payload cannot name the uuid of the object it creates, so a draft save cannot reach another draft. A blocked task (`blockedBy` names an open task) also waits out of sight in the portal: it is not listed or counted, and cannot be read or completed by its uuid until the blocker closes. `blockedBy` accepts only a task the caller may read. +- **Upgrade note: an email notification to a referenced address follows only a reference that names its schema (`external-recipient-opt-out`, REQ-ERO-007).** Each hop of the recipient reference must be an object of the schema the reference property's `$ref` points at. A rule whose reference property declares no `$ref` (a plain uuid string) now resolves to `reference-not-found` and sends nothing; give the property a `$ref` to keep such a rule working. +- **Object tree and relation graph: the doelbinding rule and property rules apply (`object-tree-read`, #4656).** `GET /api/objects/{register}/{schema}/{id}/tree`, `.../graph` and `.../graph/export` refuse a read that names no purpose where the schema asks for one (403, as the object read). The `fields` the tree copies onto each node are read through the same redaction as every other read: write-only properties, property-level `authorization.read` and encrypted values are never returned raw. A level wider than the instance node cap is no longer read in full (`truncatedBy: cap`), a failing access check answers `truncatedBy: unreadable`, and the topmost ancestor no longer names a parent the caller may not read. The tree route is limited to 60 requests a minute per user. +- **Bulk writes no longer let a schema property named like a metadata column replace it (#4661).** A schema property whose column is a metadata column (`_owner`, `_organisation`, `_authorization`, `_status`, `_deleted`, ...; also `_Owner` or `__owner_`) is not written by the bulk or the single-object writer: the server-stamped value stays. Such a property reads back empty; rename it (a property that merely starts with an underscore, such as `_meta`, is unaffected). +- **Grants and the owning group leave audit-trail rows (`special-right-to-manage-grants`, #4601).** Adding or revoking a grant on a record, and changing its owning group, write `grant_added`, `grant_revoked` and `owner_group_changed` entries naming who acted, the principal and whether the caller was admitted as owner or as holder of the special right `grant_manage`; a change of the list that holds the right is written as a `settings.updated` row. A holder of `grant_manage` is also refused a grant to a remote user or remote group, which cannot be proved not to be itself. +- **A schema written by the configuration import, the import endpoint or the AI and MCP tools re-checks its published forms (`form-destination-validator`).** Until now only the schema API did. A write that changes `properties` or `required` unpublishes the forms it breaks and notifies their authors; an owning app's `formDestinationBreak: refuse` cannot refuse a write that has no response, so these forms are unpublished and the log says so. +- **Upgrade note: the BRP resident count needs an integriq whose `CallService::call` has the `withholdBodies` parameter (#4657).** That parameter exists on integriq `development` only. Against a connector without it `countResidentsAtAddressObject()` makes no call and answers `{ unavailable: true, cause: "connector-cannot-withhold-bodies" }` (logged at info level, not as an outage); upgrade integriq together with OpenRegister to use the count. +- **Deferred listener work fits the job limit of the running Nextcloud (`deferred-listener-batches-stay-under-the-job-limit`, #4616).** Chunks are cut to 4000 characters on Nextcloud 32 and 33 and 32000 from 34. An update notification whose previous data alone is larger than that is queued without it (conditioned `updated` rules and `calculatedChange` rules are skipped for that object, and the log names it). A webhook whose payload exceeds the limit is logged and not delivered. - **Dashboard right sidebar is now collapsed by default on initial load.** The dashboard view (route `/`) renders with its right-hand app sidebar closed so the dashboard's primary content is unobstructed on arrival. The sidebar remains fully functional — the standard Nextcloud sidebar toggle opens it, and its filters, system totals, and orphaned-items sections behave identically once open. No other view's sidebar default open/closed state changes. (`dashboard-sidebar-default-state`) - **File access is permission-based and ownership-agnostic; objects can link files owned by other users.** `FileValidationHandler::checkOwnership()` now grants access on `Node::isReadable()` alone instead of denying when the file's owner UID differs from the session user — fixing `File is not owned by the current session` errors when uploading, viewing, updating, or deleting a file reached via a Nextcloud share or owned by a user other than the `openregister` system user. The update and delete paths now assert `Node::isUpdateable()` / `Node::isDeletable()` before mutating (failing fast with `NotPermittedException` in addition to Nextcloud's native enforcement), and `FileOwnershipHandler::transferFileOwnershipIfNeeded()` no longer re-owns a file to the system user when the current user already has write rights — ownership follows the folder's mount owner (the system user inside the OpenRegister folder; the original owner for a folder linked from outside it), with re-owning retained only as a fallback. (`file-permission-share-access`) - **`POST /api/files/{fileId}/anonymize` on PDF inputs now produces a clean output instead of a corrupted file.** Tenants that relied on the previous (broken) behaviour — where the byte-level `str_ireplace` path returned an unopenable PDF — need to verify nothing in their downstream pipeline assumed a corrupt PDF (e.g. mailers that retried on parse failure, viewer dashboards that rendered placeholders for unopenable attachments). The endpoint URL, auth model, and request shape are unchanged. PDF-specific failure responses are now structured (`{ "error": "pdf_anonymisation_failed", "reason": "", "details": { ... } }`) with reason-specific HTTP statuses — see the `### Added` entry for the full reason-to-status table. Encrypted PDFs are explicitly rejected (HTTP 422); image-only scans defer to the OCR capability (HTTP 422 with `text_layer_missing` reason); the strict-mode validation gate fails closed (HTTP 500 with `validation_failed`) when any substitution-map entry still appears in the re-extracted output. (`pdf-anonymisation`) diff --git a/appinfo/info.xml b/appinfo/info.xml index 9ed1aa7004..adb05bc456 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -182,6 +182,7 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\BackgroundJob\FlowRunWorker OCA\OpenRegister\BackgroundJob\FlowScheduleWorker OCA\OpenRegister\BackgroundJob\FlowTimerWorker + OCA\OpenRegister\BackgroundJob\CasePlanTimerRearmJob OCA\OpenRegister\BackgroundJob\HandoffQueueDrainJob OCA\OpenRegister\BackgroundJob\TemporalCalculationSweepJob OCA\OpenRegister\BackgroundJob\DsarDpiaDetectionJob diff --git a/docs/features/form-destination.md b/docs/features/form-destination.md index c4869fa631..bf9242ea69 100644 --- a/docs/features/form-destination.md +++ b/docs/features/form-destination.md @@ -81,10 +81,16 @@ 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. 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/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. diff --git a/l10n/be.js b/l10n/be.js index bb6a8a0854..5f316f797d 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": "ИИ-чат", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Прысутнічае", "Re-import": "Імпартаваць зноў", "Reading descriptors …": "Чытанне дэскрыптараў …", + "Re-import {register}?": "Імпартаваць {register} зноў?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Паўторны імпарт {app} бярэ аўтарызацыю яго схем з файла, замяняючы любыя змены, зробленыя на гэтым серверы, і можа паўторна імпартаваць кожны рэестр праграмы, а не толькі {register}.", + "Authorization taken from the file over a local change: {count}.": "Аўтарызацыя, узятая з файла замест лакальнай змены: {count}.", "Register descriptors": "Дэскрыптары рэестраў", "ships v{shipped}": "пастаўляе v{shipped}", "State": "Стан", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Слупок ідэнтыфікатара", "Import JSON": "Імпартаваць JSON", "Imported \"{name}\".": "\"{name}\" імпартаваны.", + "Replaced \"{name}\".": "\"{name}\" заменены.", "Imports find the pack by this slug.": "Імпарты знаходзяць пакет па гэтым слагу.", "Join columns": "Аб'яднаць слупкі", "Joined with {fields}, separated by \"{separator}\"": "Аб'яднана з {fields}, раздзяляльнік \"{separator}\"", 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..1eaa1e32cb 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 чат", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Наличен", "Re-import": "Импортирайте отново", "Reading descriptors …": "Четене на дескриптори …", + "Re-import {register}?": "Да се импортира ли отново {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Повторното импортиране на {app} взема оторизацията на схемите му от файла, като заменя всички промени, направени в тази инсталация, и може да импортира отново всеки регистър на приложението, не само {register}.", + "Authorization taken from the file over a local change: {count}.": "Оторизация, взета от файла вместо локална промяна: {count}.", "Register descriptors": "Дескриптори на регистри", "ships v{shipped}": "доставя v{shipped}", "State": "Състояние", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Колона с идентификатор", "Import JSON": "Импортиране на JSON", "Imported \"{name}\".": "\"{name}\" е импортиран.", + "Replaced \"{name}\".": "\"{name}\" е заменен.", "Imports find the pack by this slug.": "Импортиранията намират пакета по този slug.", "Join columns": "Обединяване на колони", "Joined with {fields}, separated by \"{separator}\"": "Обединено с {fields}, разделено с \"{separator}\"", 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..587ff5d4c4 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Postoji", "Re-import": "Ponovo uvezi", "Reading descriptors …": "Čitanje deskriptora …", + "Re-import {register}?": "Ponovo uvesti {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ponovnim uvozom {app} ovlaštenja njegovih shema preuzimaju se iz datoteke, čime se zamjenjuju sve promjene napravljene na ovoj instanci, a mogu se ponovo uvesti i svi registri aplikacije, ne samo {register}.", + "Authorization taken from the file over a local change: {count}.": "Ovlaštenja preuzeta iz datoteke umjesto lokalne promjene: {count}.", "Register descriptors": "Deskriptori registara", "ships v{shipped}": "isporučuje v{shipped}", "State": "Stanje", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Kolona s id-om", "Import JSON": "Uvezi JSON", "Imported \"{name}\".": "\"{name}\" je uvezen.", + "Replaced \"{name}\".": "\"{name}\" je zamijenjen.", "Imports find the pack by this slug.": "Uvozi pronalaze paket po ovom slugu.", "Join columns": "Spoji kolone", "Joined with {fields}, separated by \"{separator}\"": "Spojeno s {fields}, razdvojeno sa \"{separator}\"", 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..f2915d7e6a 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Present", "Re-import": "Reimporta", "Reading descriptors …": "S’estan llegint els descriptors …", + "Re-import {register}?": "Reimportar {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "En reimportar {app}, l'autorització dels seus esquemes es pren del fitxer i substitueix qualsevol canvi fet en aquesta instància, i es poden reimportar tots els registres de l'aplicació, no només {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorització presa del fitxer sobre un canvi local: {count}.", "Register descriptors": "Descriptors de registre", "ships v{shipped}": "lliura v{shipped}", "State": "Estat", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Columna d'id", "Import JSON": "Importar JSON", "Imported \"{name}\".": "S'ha importat \"{name}\".", + "Replaced \"{name}\".": "S'ha substituït \"{name}\".", "Imports find the pack by this slug.": "Les importacions troben el paquet per aquest slug.", "Join columns": "Unir columnes", "Joined with {fields}, separated by \"{separator}\"": "Unit amb {fields}, separat per \"{separator}\"", 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..932f64016c 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Přítomen", "Re-import": "Znovu importovat", "Reading descriptors …": "Načítání deskriptorů …", + "Re-import {register}?": "Znovu importovat {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Opětovný import aplikace {app} převezme autorizaci jejích schémat ze souboru a nahradí všechny změny provedené v této instanci; může také znovu importovat všechny registry aplikace, nejen {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorizace převzatá ze souboru namísto místní změny: {count}.", "Register descriptors": "Deskriptory registrů", "ships v{shipped}": "dodává v{shipped}", "State": "Stav", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Sloupec s ID", "Import JSON": "Importovat JSON", "Imported \"{name}\".": "Balíček \"{name}\" byl naimportován.", + "Replaced \"{name}\".": "Balíček \"{name}\" byl nahrazen.", "Imports find the pack by this slug.": "Importy najdou balíček podle tohoto slugu.", "Join columns": "Spojit sloupce", "Joined with {fields}, separated by \"{separator}\"": "Spojeno s {fields}, odděleno \"{separator}\"", 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..a69e3fba56 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Til stede", "Re-import": "Genimportér", "Reading descriptors …": "Læser deskriptorer …", + "Re-import {register}?": "Genimportér {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Genimport af {app} henter autorisationen for dens skemaer fra filen og erstatter alle ændringer, der er foretaget på denne instans, og kan genimportere alle appens registre, ikke kun {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorisation hentet fra filen over en lokal ændring: {count}.", "Register descriptors": "Register-deskriptorer", "ships v{shipped}": "leverer v{shipped}", "State": "Status", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Id-kolonne", "Import JSON": "Importér JSON", "Imported \"{name}\".": "\"{name}\" er importeret.", + "Replaced \"{name}\".": "\"{name}\" er erstattet.", "Imports find the pack by this slug.": "Importer finder pakken via denne slug.", "Join columns": "Sammenføj kolonner", "Joined with {fields}, separated by \"{separator}\"": "Sammenføjet med {fields}, adskilt af \"{separator}\"", 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..99091f3f1f 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Vorhanden", "Re-import": "Erneut importieren", "Reading descriptors …": "Deskriptoren werden gelesen …", + "Re-import {register}?": "{register} erneut importieren?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Beim erneuten Import von {app} wird die Autorisierung seiner Schemata aus der Datei übernommen und jede auf dieser Instanz vorgenommene Änderung ersetzt; dabei können auch alle Register der App erneut importiert werden, nicht nur {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorisierung aus der Datei über eine lokale Änderung hinweg übernommen: {count}.", "Register descriptors": "Register-Deskriptoren", "ships v{shipped}": "liefert v{shipped}", "State": "Status", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "ID-Spalte", "Import JSON": "JSON importieren", "Imported \"{name}\".": "\"{name}\" importiert.", + "Replaced \"{name}\".": "\"{name}\" ersetzt.", "Imports find the pack by this slug.": "Importe finden das Paket über diesen Slug.", "Join columns": "Spalten verbinden", "Joined with {fields}, separated by \"{separator}\"": "Verbunden mit {fields}, getrennt durch \"{separator}\"", 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..1afb7fc563 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Υπάρχει", "Re-import": "Επανεισαγωγή", "Reading descriptors …": "Ανάγνωση περιγραφέων …", + "Re-import {register}?": "Επανεισαγωγή του {register};", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Η επανεισαγωγή της εφαρμογής {app} παίρνει την εξουσιοδότηση των σχημάτων της από το αρχείο, αντικαθιστώντας κάθε αλλαγή που έγινε σε αυτήν την εγκατάσταση, και μπορεί να επανεισαγάγει όλα τα μητρώα της εφαρμογής, όχι μόνο το {register}.", + "Authorization taken from the file over a local change: {count}.": "Εξουσιοδότηση που ελήφθη από το αρχείο πάνω από τοπική αλλαγή: {count}.", "Register descriptors": "Περιγραφείς μητρώων", "ships v{shipped}": "παρέχει v{shipped}", "State": "Κατάσταση", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Στήλη id", "Import JSON": "Εισαγωγή JSON", "Imported \"{name}\".": "Το \"{name}\" εισήχθη.", + "Replaced \"{name}\".": "Το \"{name}\" αντικαταστάθηκε.", "Imports find the pack by this slug.": "Οι εισαγωγές βρίσκουν το πακέτο μέσω αυτού του slug.", "Join columns": "Συνένωση στηλών", "Joined with {fields}, separated by \"{separator}\"": "Συνενωμένο με {fields}, με διαχωριστικό \"{separator}\"", 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/en.js b/l10n/en.js index f827348952..68360fd1ba 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:", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Present", "Re-import": "Re-import", "Reading descriptors …": "Reading descriptors …", + "Re-import {register}?": "Re-import {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.", + "Authorization taken from the file over a local change: {count}.": "Authorization taken from the file over a local change: {count}.", "Register descriptors": "Register descriptors", "ships v{shipped}": "ships v{shipped}", "State": "State", @@ -3405,6 +3408,7 @@ OC.L10N.register( "Id column": "Id column", "Import JSON": "Import JSON", "Imported \"{name}\".": "Imported \"{name}\".", + "Replaced \"{name}\".": "Replaced \"{name}\".", "Imports find the pack by this slug.": "Imports find the pack by this slug.", "Join columns": "Join columns", "Joined with {fields}, separated by \"{separator}\"": "Joined with {fields}, separated by \"{separator}\"", 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/es.js b/l10n/es.js index 38f1bb0cef..e56887ecad 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Presente", "Re-import": "Reimportar", "Reading descriptors …": "Leyendo descriptores …", + "Re-import {register}?": "¿Reimportar {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Al reimportar {app}, la autorización de sus esquemas se toma del archivo y sustituye cualquier cambio hecho en esta instancia, y puede reimportarse cada registro de la aplicación, no solo {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorización tomada del archivo sobre un cambio local: {count}.", "Register descriptors": "Descriptores de registro", "ships v{shipped}": "entrega v{shipped}", "State": "Estado", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Columna de id", "Import JSON": "Importar JSON", "Imported \"{name}\".": "Se ha importado \"{name}\".", + "Replaced \"{name}\".": "Se ha reemplazado \"{name}\".", "Imports find the pack by this slug.": "Las importaciones encuentran el paquete por este slug.", "Join columns": "Unir columnas", "Joined with {fields}, separated by \"{separator}\"": "Unido con {fields}, separado por \"{separator}\"", 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..abd01772c2 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Olemas", "Re-import": "Impordi uuesti", "Reading descriptors …": "Kirjeldajate lugemine …", + "Re-import {register}?": "Kas importida {register} uuesti?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "{app} uuesti importimine võtab selle skeemide volitused failist, asendades kõik sellel instantsil tehtud muudatused, ning võib uuesti importida kõik rakenduse registrid, mitte ainult {register}.", + "Authorization taken from the file over a local change: {count}.": "Failist üle võetud volitused kohaliku muudatuse asemel: {count}.", "Register descriptors": "Registrikirjeldajad", "ships v{shipped}": "tarnib v{shipped}", "State": "Olek", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "ID-veerg", "Import JSON": "Impordi JSON", "Imported \"{name}\".": "„{name}“ on imporditud.", + "Replaced \"{name}\".": "„{name}“ on asendatud.", "Imports find the pack by this slug.": "Impordid leiavad paketi selle identifikaatori järgi.", "Join columns": "Ühenda veerud", "Joined with {fields}, separated by \"{separator}\"": "Ühendatud väljadega {fields}, eraldajaks „{separator}“", 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..21999dfe79 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Olemassa", "Re-import": "Tuo uudelleen", "Reading descriptors …": "Luetaan kuvauksia …", + "Re-import {register}?": "Tuodaanko {register} uudelleen?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Kohteen {app} uudelleentuonti ottaa sen skeemojen valtuutukset tiedostosta ja korvaa kaikki tässä instanssissa tehdyt muutokset, ja se voi tuoda uudelleen sovelluksen kaikki rekisterit, ei vain rekisteriä {register}.", + "Authorization taken from the file over a local change: {count}.": "Tiedostosta otetut valtuutukset paikallisen muutoksen sijaan: {count}.", "Register descriptors": "Rekisterikuvaukset", "ships v{shipped}": "toimittaa v{shipped}", "State": "Tila", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Tunnussarake", "Import JSON": "Tuo JSON", "Imported \"{name}\".": "\"{name}\" tuotiin.", + "Replaced \"{name}\".": "\"{name}\" korvattiin.", "Imports find the pack by this slug.": "Tuonnit löytävät paketin tämän slugin avulla.", "Join columns": "Yhdistä sarakkeita", "Joined with {fields}, separated by \"{separator}\"": "Yhdistetty sarakkeisiin {fields}, erottimena \"{separator}\"", 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..f819484aba 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Présent", "Re-import": "Réimporter", "Reading descriptors …": "Lecture des descripteurs …", + "Re-import {register}?": "Réimporter {register} ?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "La réimportation de {app} reprend l'autorisation de ses schémas depuis le fichier, en remplaçant toute modification faite sur cette instance, et peut réimporter tous les registres de l'application, pas seulement {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorisation reprise du fichier à la place d'une modification locale : {count}.", "Register descriptors": "Descripteurs de registre", "ships v{shipped}": "fournit v{shipped}", "State": "État", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Colonne d'identifiant", "Import JSON": "Importer du JSON", "Imported \"{name}\".": "« {name} » importé.", + "Replaced \"{name}\".": "« {name} » remplacé.", "Imports find the pack by this slug.": "Les imports trouvent le pack grâce à ce slug.", "Join columns": "Joindre des colonnes", "Joined with {fields}, separated by \"{separator}\"": "Joint avec {fields}, séparé par « {separator} »", 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..cfce8204bb 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "I láthair", "Re-import": "Athiompórtáil", "Reading descriptors …": "Tuairisceoirí á léamh …", + "Re-import {register}?": "An ndéanfar {register} a athiompórtáil?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Nuair a athiompórtáiltear {app}, tógtar údarú a scéimrí ón gcomhad, ag cur in ionad aon athraithe a rinneadh ar an gcás seo, agus is féidir gach clár den aip a athiompórtáil, ní hamháin {register}.", + "Authorization taken from the file over a local change: {count}.": "Údarú a tógadh ón gcomhad in ionad athraithe áitiúil: {count}.", "Register descriptors": "Tuairisceoirí clár", "ships v{shipped}": "soláthraíonn sé v{shipped}", "State": "Staid", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Colún id", "Import JSON": "Iompórtáil JSON", "Imported \"{name}\".": "Iompórtáladh \"{name}\".", + "Replaced \"{name}\".": "Athsholáthraíodh \"{name}\".", "Imports find the pack by this slug.": "Aimsíonn iompórtálacha an pacáiste tríd an slug seo.", "Join columns": "Cumaisc colúin", "Joined with {fields}, separated by \"{separator}\"": "Cumascthe le {fields}, scartha le \"{separator}\"", 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..b6a8e70cd8 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Postoji", "Re-import": "Ponovno uvezi", "Reading descriptors …": "Čitanje deskriptora …", + "Re-import {register}?": "Ponovno uvesti {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ponovnim uvozom aplikacije {app} ovlaštenja njezinih shema preuzimaju se iz datoteke i zamjenjuju sve promjene napravljene na ovoj instanci, a mogu se ponovno uvesti i svi registri aplikacije, ne samo {register}.", + "Authorization taken from the file over a local change: {count}.": "Ovlaštenja preuzeta iz datoteke umjesto lokalne promjene: {count}.", "Register descriptors": "Deskriptori registara", "ships v{shipped}": "isporučuje v{shipped}", "State": "Stanje", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Stupac s id-om", "Import JSON": "Uvezi JSON", "Imported \"{name}\".": "\"{name}\" je uvezen.", + "Replaced \"{name}\".": "\"{name}\" je zamijenjen.", "Imports find the pack by this slug.": "Uvozi pronalaze paket po ovom slugu.", "Join columns": "Spoji stupce", "Joined with {fields}, separated by \"{separator}\"": "Spojeno s {fields}, odvojeno s \"{separator}\"", 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..b9062ad342 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Jelen van", "Re-import": "Újraimportálás", "Reading descriptors …": "Leírók olvasása …", + "Re-import {register}?": "Újraimportálja a(z) {register} nyilvántartást?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "A(z) {app} újraimportálása a sémái jogosultságait a fájlból veszi át, felülírva az ezen a példányon végzett összes módosítást, és az alkalmazás összes nyilvántartását újraimportálhatja, nem csak a(z) {register} nyilvántartást.", + "Authorization taken from the file over a local change: {count}.": "A fájlból átvett jogosultságok helyi módosítás helyett: {count}.", "Register descriptors": "Regiszterleírók", "ships v{shipped}": "szállítja: v{shipped}", "State": "Állapot", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Azonosító oszlop", "Import JSON": "JSON importálása", "Imported \"{name}\".": "\"{name}\" importálva.", + "Replaced \"{name}\".": "\"{name}\" lecserélve.", "Imports find the pack by this slug.": "Az importálások ezen a slugon keresztül találják meg a csomagot.", "Join columns": "Oszlopok egyesítése", "Joined with {fields}, separated by \"{separator}\"": "Egyesítve ezekkel: {fields}, elválasztó: \"{separator}\"", 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..5adae0738e 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Til staðar", "Re-import": "Flytja inn aftur", "Reading descriptors …": "Les lýsingar …", + "Re-import {register}?": "Flytja {register} inn aftur?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Þegar {app} er flutt inn aftur eru heimildir skemu þess teknar úr skránni og koma í stað allra breytinga sem gerðar voru á þessu tilviki, og það getur flutt inn aftur allar skrár forritsins, ekki bara {register}.", + "Authorization taken from the file over a local change: {count}.": "Heimildir sem teknar voru úr skránni í stað staðbundinnar breytingar: {count}.", "Register descriptors": "Skrárlýsingar", "ships v{shipped}": "skilar v{shipped}", "State": "Staða", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Auðkennisdálkur", "Import JSON": "Flytja inn JSON", "Imported \"{name}\".": "\"{name}\" var flutt inn.", + "Replaced \"{name}\".": "\"{name}\" var skipt út.", "Imports find the pack by this slug.": "Innflutningar finna pakkann eftir þessu auðkenni.", "Join columns": "Sameina dálka", "Joined with {fields}, separated by \"{separator}\"": "Sameinað með {fields}, aðskilið með \"{separator}\"", 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..c73fbb739d 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Presente", "Re-import": "Reimporta", "Reading descriptors …": "Lettura dei descrittori …", + "Re-import {register}?": "Reimportare {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "La reimportazione di {app} acquisisce dal file l'autorizzazione dei suoi schemi, sostituendo qualsiasi modifica apportata su questa istanza, e può reimportare tutti i registri dell'app, non solo {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorizzazione acquisita dal file al posto di una modifica locale: {count}.", "Register descriptors": "Descrittori di registro", "ships v{shipped}": "fornisce v{shipped}", "State": "Stato", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Colonna ID", "Import JSON": "Importa JSON", "Imported \"{name}\".": "\"{name}\" importato.", + "Replaced \"{name}\".": "\"{name}\" sostituito.", "Imports find the pack by this slug.": "Le importazioni trovano il pacchetto tramite questo slug.", "Join columns": "Unisci colonne", "Joined with {fields}, separated by \"{separator}\"": "Unito con {fields}, separato da \"{separator}\"", 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..acc596d526 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Do", "Re-import": "Nei importéieren", "Reading descriptors …": "Deskriptore ginn gelies …", + "Re-import {register}?": "{register} nei importéieren?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Beim neien Import vun {app} gëtt d'Autorisatioun vu senge Schemaen aus der Datei iwwerholl a jiddwer Ännerung op dëser Instanz ersat, a kann all Register vun der App nei importéiert ginn, net nëmmen {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorisatioun aus der Datei amplaz vun enger lokaler Ännerung iwwerholl: {count}.", "Register descriptors": "Register-Deskriptoren", "ships v{shipped}": "liwwert v{shipped}", "State": "Status", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "ID-Kolonn", "Import JSON": "JSON importéieren", "Imported \"{name}\".": "\"{name}\" gouf importéiert.", + "Replaced \"{name}\".": "\"{name}\" gouf ersat.", "Imports find the pack by this slug.": "Importer fannen de Pak iwwer dëse Kuerznumm.", "Join columns": "Kolonne verbannen", "Joined with {fields}, separated by \"{separator}\"": "Verbonne mat {fields}, getrennt duerch \"{separator}\"", 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..fa95ed416c 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Yra", "Re-import": "Importuoti iš naujo", "Reading descriptors …": "Skaitomi deskriptoriai …", + "Re-import {register}?": "Importuoti {register} iš naujo?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Iš naujo importuojant {app}, jos schemų įgaliojimai paimami iš failo, pakeičiant visus šiame egzemplioriuje atliktus pakeitimus, ir gali būti iš naujo importuoti visi programos registrai, ne tik {register}.", + "Authorization taken from the file over a local change: {count}.": "Įgaliojimai, paimti iš failo vietoj vietinio pakeitimo: {count}.", "Register descriptors": "Registrų deskriptoriai", "ships v{shipped}": "pateikia v{shipped}", "State": "Būsena", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Id stulpelis", "Import JSON": "Importuoti JSON", "Imported \"{name}\".": "\"{name}\" importuotas.", + "Replaced \"{name}\".": "\"{name}\" pakeistas.", "Imports find the pack by this slug.": "Importai randa paketą pagal šį trumpąjį pavadinimą.", "Join columns": "Sujungti stulpelius", "Joined with {fields}, separated by \"{separator}\"": "Sujungta su {fields}, atskirta \"{separator}\"", 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..025806edd6 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Klātesošs", "Re-import": "Importēt atkārtoti", "Reading descriptors …": "Notiek deskriptoru lasīšana …", + "Re-import {register}?": "Vai importēt {register} atkārtoti?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Atkārtoti importējot {app}, tās shēmu autorizācija tiek ņemta no faila, aizstājot visas šajā instancē veiktās izmaiņas, un var tikt atkārtoti importēti visi lietotnes reģistri, ne tikai {register}.", + "Authorization taken from the file over a local change: {count}.": "No faila ņemta autorizācija vietējas izmaiņas vietā: {count}.", "Register descriptors": "Reģistru deskriptori", "ships v{shipped}": "piegādā v{shipped}", "State": "Stāvoklis", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "ID kolonna", "Import JSON": "Importēt JSON", "Imported \"{name}\".": "Pakotne \"{name}\" ir importēta.", + "Replaced \"{name}\".": "Pakotne \"{name}\" ir aizstāta.", "Imports find the pack by this slug.": "Importi atrod pakotni pēc šī identifikatora.", "Join columns": "Apvienot kolonnas", "Joined with {fields}, separated by \"{separator}\"": "Apvienots ar {fields}, atdalīts ar \"{separator}\"", 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..e70f095d39 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 разговор", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Постои", "Re-import": "Увези повторно", "Reading descriptors …": "Читање дескриптори …", + "Re-import {register}?": "Да се увезе ли повторно {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Повторното увезување на {app} ја презема авторизацијата на нејзините шеми од датотеката, заменувајќи ги сите промени направени на оваа инстанца, и може повторно да ги увезе сите регистри на апликацијата, не само {register}.", + "Authorization taken from the file over a local change: {count}.": "Авторизација преземена од датотеката наместо локална промена: {count}.", "Register descriptors": "Дескриптори на регистри", "ships v{shipped}": "испорачува v{shipped}", "State": "Состојба", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Колона со идентификатор", "Import JSON": "Увези JSON", "Imported \"{name}\".": "\"{name}\" е увезен.", + "Replaced \"{name}\".": "\"{name}\" е заменет.", "Imports find the pack by this slug.": "Увозите го наоѓаат пакетот според овој slug.", "Join columns": "Спој колони", "Joined with {fields}, separated by \"{separator}\"": "Споено со {fields}, одделено со \"{separator}\"", 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..1efbb110d0 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Preżenti", "Re-import": "Erġa’ importa", "Reading descriptors …": "Qed jinqraw id-deskritturi …", + "Re-import {register}?": "Terġa’ timporta {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Meta terġa’ timporta {app}, l-awtorizzazzjoni tal-iskemi tagħha tittieħed mill-fajl, tissostitwixxi kull bidla magħmula f’din l-istanza, u jistgħu jiġu importati mill-ġdid ir-reġistri kollha tal-applikazzjoni, mhux biss {register}.", + "Authorization taken from the file over a local change: {count}.": "Awtorizzazzjoni meħuda mill-fajl minflok bidla lokali: {count}.", "Register descriptors": "Deskritturi tar-reġistri", "ships v{shipped}": "iwassal v{shipped}", "State": "Stat", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Kolonna tal-ID", "Import JSON": "Importa JSON", "Imported \"{name}\".": "\"{name}\" ġie importat.", + "Replaced \"{name}\".": "\"{name}\" ġie sostitwit.", "Imports find the pack by this slug.": "L-importazzjonijiet isibu l-pakkett b'dan is-slug.", "Join columns": "Għaqqad il-kolonni", "Joined with {fields}, separated by \"{separator}\"": "Magħqud ma' {fields}, separat b'\"{separator}\"", 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..c8122b7300 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Finnes", "Re-import": "Importer på nytt", "Reading descriptors …": "Leser deskriptorer …", + "Re-import {register}?": "Importere {register} på nytt?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ny import av {app} henter autorisasjonen for skjemaene fra filen og erstatter alle endringer som er gjort på denne instansen, og kan importere alle appens registre på nytt, ikke bare {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorisasjon hentet fra filen over en lokal endring: {count}.", "Register descriptors": "Registerdeskriptorer", "ships v{shipped}": "leverer v{shipped}", "State": "Status", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Id-kolonne", "Import JSON": "Importer JSON", "Imported \"{name}\".": "\"{name}\" er importert.", + "Replaced \"{name}\".": "\"{name}\" er erstattet.", "Imports find the pack by this slug.": "Importer finner pakken via denne slugen.", "Join columns": "Slå sammen kolonner", "Joined with {fields}, separated by \"{separator}\"": "Slått sammen med {fields}, skilt med \"{separator}\"", 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/nl.js b/l10n/nl.js index 54ebbc735e..5aa41ca773 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", @@ -2820,6 +2820,9 @@ OC.L10N.register( "Present": "Aanwezig", "Re-import": "Opnieuw importeren", "Reading descriptors …": "Descriptors lezen …", + "Re-import {register}?": "{register} opnieuw importeren?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Bij het opnieuw importeren van {app} wordt de autorisatie van de schema's uit het bestand overgenomen, ook waar die op deze instantie is aangepast, en kunnen alle registers van de app opnieuw worden geïmporteerd, niet alleen {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorisatie uit het bestand overgenomen boven een lokale wijziging: {count}.", "Register descriptors": "Register-descriptors", "ships v{shipped}": "levert v{shipped}", "State": "Status", @@ -3467,6 +3470,7 @@ OC.L10N.register( "Id column": "Id-kolom", "Import JSON": "JSON importeren", "Imported \"{name}\".": "\"{name}\" is geïmporteerd.", + "Replaced \"{name}\".": "\"{name}\" is vervangen.", "Imports find the pack by this slug.": "Imports vinden het pakket via deze slug.", "Join columns": "Kolommen samenvoegen", "Joined with {fields}, separated by \"{separator}\"": "Samengevoegd met {fields}, gescheiden door \"{separator}\"", diff --git a/l10n/nl.json b/l10n/nl.json index f4e28ff085..9b8005ea48 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", @@ -3440,6 +3440,33 @@ "The id of a built-in migration pack cannot be changed": "Het id van een ingebouwd migratiepakket kan niet worden gewijzigd", "The request must contain a pack definition object": "Het verzoek moet een object met een pakketdefinitie bevatten", "Sample rows must be a list of objects": "Voorbeeldrijen moeten een lijst van objecten zijn", + "\"%s\" is required and must be a non-empty string": "\"%s\" is verplicht en moet een niet-lege tekenreeks zijn", + "\"id\" must be a lowercase slug (letters, digits, hyphens), got \"%s\"": "\"id\" moet een slug in kleine letters zijn (letters, cijfers, koppeltekens), maar is \"%s\"", + "%1$s must be one of: %2$s (got %3$s)": "%1$s moet een van deze zijn: %2$s (gekregen: %3$s)", + "\"version\" must be a semver string (MAJOR.MINOR.PATCH), got %s": "\"version\" moet een semver-tekenreeks zijn (MAJOR.MINOR.PATCH), maar is %s", + "\"fieldMappings\" is required and must be a non-empty array": "\"fieldMappings\" is verplicht en moet een niet-lege lijst zijn", + "Mapping row %s must be an object": "Toewijzingsrij %s moet een object zijn", + "Mapping row %1$s: %2$s is required and must be a non-empty string": "Toewijzingsrij %1$s: %2$s is verplicht en moet een niet-lege tekenreeks zijn", + "Mapping row %s: required must be a boolean when present": "Toewijzingsrij %s: required moet een boolean zijn wanneer opgegeven", + "Mapping row %s: transform": "Toewijzingsrij %s: transform", + "%s must be an object with a \"type\" key": "%s moet een object met een \"type\"-sleutel zijn", + "%s must be a string when present": "%s moet een tekenreeks zijn wanneer opgegeven", + "%1$s is required and must be a non-empty object for a %2$s transform": "%1$s is verplicht en moet een niet-leeg object zijn voor een %2$s-omzetting", + "%1$s is required and must be a non-empty array for a %2$s transform": "%1$s is verplicht en moet een niet-lege lijst zijn voor een %2$s-omzetting", + "%s must be a non-empty string": "%s moet een niet-lege tekenreeks zijn", + "%1$s is required for a %2$s transform": "%1$s is verplicht voor een %2$s-omzetting", + "\"defaults\" must be an object of target-property => default value when present": "\"defaults\" moet een object van doeleigenschap => standaardwaarde zijn wanneer opgegeven", + "\"skipRows\" must be an array of row numbers when present": "\"skipRows\" moet een lijst met rijnummers zijn wanneer opgegeven", + "\"skipRows\" entries must be positive integers": "De waarden in \"skipRows\" moeten positieve gehele getallen zijn", + "\"idStrategy\" is required and must be an object with a \"type\" key": "\"idStrategy\" is verplicht en moet een object met een \"type\"-sleutel zijn", + "idStrategy.field is required and must be a non-empty string when idStrategy.type is \"sourceField\"": "idStrategy.field is verplicht en moet een niet-lege tekenreeks zijn wanneer idStrategy.type \"sourceField\" is", + "Required source field \"%s\" is missing or empty": "Verplicht bronveld \"%s\" ontbreekt of is leeg", + "Unknown transform type \"%s\"": "Onbekend omzettingstype \"%s\"", + "Could not parse date \"%1$s\" with format \"%2$s\"": "Kan de datum \"%1$s\" niet lezen met de notatie \"%2$s\"", + "Could not parse date \"%1$s\": %2$s": "Kan de datum \"%1$s\" niet lezen: %2$s", + "Value \"%s\" has no mapping and no default is configured": "Waarde \"%s\" heeft geen toewijzing en er is geen standaardwaarde ingesteld", + "An \"id\" in the request body that equals the row id in the URL cannot be told apart from it.": "Een \"id\" in de aanvraag die gelijk is aan de rij-id in de URL, is daar niet van te onderscheiden.", + "Send the pack as {\"definition\": {...}} to keep that id.": "Stuur het pakket als {\"definition\": {...}} om die id te behouden.", "A preview takes at most %s sample rows": "Een voorvertoning neemt maximaal %s voorbeeldrijen aan", "The pack definition is invalid": "De pakketdefinitie is ongeldig", "Failed to preview migration pack": "De voorvertoning van het migratiepakket is mislukt", diff --git a/l10n/pl.js b/l10n/pl.js index f91f12fb6e..f186acc437 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Obecny", "Re-import": "Importuj ponownie", "Reading descriptors …": "Odczytywanie deskryptorów …", + "Re-import {register}?": "Zaimportować ponownie {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ponowny import aplikacji {app} przejmuje z pliku autoryzację jej schematów, zastępując wszelkie zmiany wprowadzone w tej instancji, i może ponownie zaimportować wszystkie rejestry aplikacji, nie tylko {register}.", + "Authorization taken from the file over a local change: {count}.": "Autoryzacja przejęta z pliku w miejsce zmiany lokalnej: {count}.", "Register descriptors": "Deskryptory rejestrów", "ships v{shipped}": "dostarcza v{shipped}", "State": "Stan", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Kolumna identyfikatora", "Import JSON": "Importuj JSON", "Imported \"{name}\".": "Zaimportowano \"{name}\".", + "Replaced \"{name}\".": "Zastąpiono \"{name}\".", "Imports find the pack by this slug.": "Importy znajdują pakiet po tym slugu.", "Join columns": "Połącz kolumny", "Joined with {fields}, separated by \"{separator}\"": "Połączone z {fields}, rozdzielone \"{separator}\"", 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..70671f28f2 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Presente", "Re-import": "Reimportar", "Reading descriptors …": "A ler descritores …", + "Re-import {register}?": "Reimportar {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ao reimportar {app}, a autorização dos seus esquemas é obtida do ficheiro, substituindo qualquer alteração feita nesta instância, e pode reimportar todos os registos da aplicação, não apenas {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorização obtida do ficheiro em vez de uma alteração local: {count}.", "Register descriptors": "Descritores de registo", "ships v{shipped}": "fornece v{shipped}", "State": "Estado", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Coluna de ID", "Import JSON": "Importar JSON", "Imported \"{name}\".": "\"{name}\" importado.", + "Replaced \"{name}\".": "\"{name}\" substituído.", "Imports find the pack by this slug.": "As importações encontram o pacote por este slug.", "Join columns": "Juntar colunas", "Joined with {fields}, separated by \"{separator}\"": "Juntado com {fields}, separado por \"{separator}\"", 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..f2743f52a5 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Preschent", "Re-import": "Importar danovamain", "Reading descriptors …": "Leger ils descripturs …", + "Re-import {register}?": "Importar danovamain {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Cur ch'ins importescha danovamain {app}, vegn l'autorisaziun da ses schemas surpigliada da la datoteca e remplazza mintga midada fatga sin questa instanza, e tut ils registers da l'app pon vegnir importads danovamain, buca mo {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorisaziun surpigliada da la datoteca empè d'ina midada locala: {count}.", "Register descriptors": "Descripturs da register", "ships v{shipped}": "furnescha v{shipped}", "State": "Status", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Colonna da l'id", "Import JSON": "Importar JSON", "Imported \"{name}\".": "\"{name}\" è vegnì importà.", + "Replaced \"{name}\".": "\"{name}\" è vegnì remplazzà.", "Imports find the pack by this slug.": "Imports chattan il pachet tenor quest slug.", "Join columns": "Cumbinar colonnas", "Joined with {fields}, separated by \"{separator}\"": "Cumbinà cun {fields}, separà cun \"{separator}\"", 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..4a169eff1a 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Prezent", "Re-import": "Reimportă", "Reading descriptors …": "Se citesc descriptorii …", + "Re-import {register}?": "Reimportați {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "La reimportarea aplicației {app}, autorizarea schemelor sale este preluată din fișier, înlocuind orice modificare făcută pe această instanță, și pot fi reimportate toate registrele aplicației, nu doar {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorizare preluată din fișier peste o modificare locală: {count}.", "Register descriptors": "Descriptori de registru", "ships v{shipped}": "livrează v{shipped}", "State": "Stare", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Coloană ID", "Import JSON": "Importare JSON", "Imported \"{name}\".": "\"{name}\" a fost importat.", + "Replaced \"{name}\".": "\"{name}\" a fost înlocuit.", "Imports find the pack by this slug.": "Importurile găsesc pachetul după acest slug.", "Join columns": "Concatenare coloane", "Joined with {fields}, separated by \"{separator}\"": "Concatenat cu {fields}, separat prin \"{separator}\"", 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..912a91c876 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": "ИИ-чат", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Присутствует", "Re-import": "Импортировать снова", "Reading descriptors …": "Чтение дескрипторов …", + "Re-import {register}?": "Импортировать {register} снова?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Повторный импорт {app} берёт авторизацию его схем из файла, заменяя любые изменения, сделанные на этом экземпляре, и может повторно импортировать все реестры приложения, а не только {register}.", + "Authorization taken from the file over a local change: {count}.": "Авторизация, взятая из файла вместо локального изменения: {count}.", "Register descriptors": "Дескрипторы реестров", "ships v{shipped}": "поставляет v{shipped}", "State": "Состояние", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Столбец id", "Import JSON": "Импортировать JSON", "Imported \"{name}\".": "\"{name}\" импортирован.", + "Replaced \"{name}\".": "\"{name}\" заменён.", "Imports find the pack by this slug.": "Импорты находят пакет по этому slug.", "Join columns": "Объединить столбцы", "Joined with {fields}, separated by \"{separator}\"": "Объединено с {fields}, разделитель \"{separator}\"", 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..52715e8446 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Prítomný", "Re-import": "Znovu importovať", "Reading descriptors …": "Načítavanie deskriptorov …", + "Re-import {register}?": "Znovu importovať {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Opätovný import aplikácie {app} prevezme autorizáciu jej schém zo súboru a nahradí všetky zmeny vykonané v tejto inštancii; môže znova importovať aj všetky registre aplikácie, nielen {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorizácia prevzatá zo súboru namiesto lokálnej zmeny: {count}.", "Register descriptors": "Deskriptory registrov", "ships v{shipped}": "dodáva v{shipped}", "State": "Stav", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Stĺpec s id", "Import JSON": "Importovať JSON", "Imported \"{name}\".": "\"{name}\" bol importovaný.", + "Replaced \"{name}\".": "\"{name}\" bol nahradený.", "Imports find the pack by this slug.": "Importy nájdu balík podľa tohto slugu.", "Join columns": "Spojiť stĺpce", "Joined with {fields}, separated by \"{separator}\"": "Spojené s {fields}, oddelené \"{separator}\"", 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..9621080b56 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Prisoten", "Re-import": "Ponovno uvozi", "Reading descriptors …": "Branje deskriptorjev …", + "Re-import {register}?": "Želite znova uvoziti {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ob ponovnem uvozu aplikacije {app} se avtorizacija njenih shem prevzame iz datoteke in nadomesti vse spremembe, narejene na tem primerku, ponovno pa se lahko uvozijo tudi vsi registri aplikacije, ne le {register}.", + "Authorization taken from the file over a local change: {count}.": "Avtorizacija, prevzeta iz datoteke namesto lokalne spremembe: {count}.", "Register descriptors": "Deskriptorji registrov", "ships v{shipped}": "dobavlja v{shipped}", "State": "Stanje", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Stolpec z ID", "Import JSON": "Uvozi JSON", "Imported \"{name}\".": "Paket \"{name}\" je uvožen.", + "Replaced \"{name}\".": "Paket \"{name}\" je zamenjan.", "Imports find the pack by this slug.": "Uvozi najdejo paket po tej oznaki.", "Join columns": "Združi stolpce", "Joined with {fields}, separated by \"{separator}\"": "Združeno s {fields}, ločeno z \"{separator}\"", 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..9c58edad2d 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "I pranishëm", "Re-import": "Riimporto", "Reading descriptors …": "Po lexohen përshkruesit …", + "Re-import {register}?": "Ta riimportoni {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Riimportimi i {app} merr autorizimin e skemave të tij nga skedari, duke zëvendësuar çdo ndryshim të bërë në këtë instancë, dhe mund të riimportojë çdo regjistër të aplikacionit, jo vetëm {register}.", + "Authorization taken from the file over a local change: {count}.": "Autorizim i marrë nga skedari në vend të një ndryshimi lokal: {count}.", "Register descriptors": "Përshkrues regjistrash", "ships v{shipped}": "jep v{shipped}", "State": "Gjendja", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Kolona e identifikuesit", "Import JSON": "Importo JSON", "Imported \"{name}\".": "\"{name}\" u importua.", + "Replaced \"{name}\".": "\"{name}\" u zëvendësua.", "Imports find the pack by this slug.": "Importet e gjejnë paketën sipas këtij emri të shkurtër.", "Join columns": "Bashko kolonat", "Joined with {fields}, separated by \"{separator}\"": "Bashkuar me {fields}, të ndara me \"{separator}\"", 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..64629ac9cd 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Postoji", "Re-import": "Ponovo uvezi", "Reading descriptors …": "Čitanje deskriptora …", + "Re-import {register}?": "Ponovo uvesti {register}?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ponovnim uvozom aplikacije {app} ovlaštenja njenih šema preuzimaju se iz datoteke i zamenjuju sve izmene napravljene na ovoj instanci, a mogu se ponovo uvesti i svi registri aplikacije, ne samo {register}.", + "Authorization taken from the file over a local change: {count}.": "Ovlaštenja preuzeta iz datoteke umesto lokalne izmene: {count}.", "Register descriptors": "Deskriptori registara", "ships v{shipped}": "isporučuje v{shipped}", "State": "Stanje", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Колона са ИД-ом", "Import JSON": "Увези JSON", "Imported \"{name}\".": "\"{name}\" је увезен.", + "Replaced \"{name}\".": "\"{name}\" je zamenjen.", "Imports find the pack by this slug.": "Увози проналазе пакет по овом slug-у.", "Join columns": "Спој колоне", "Joined with {fields}, separated by \"{separator}\"": "Спојено са {fields}, раздвојено знаком \"{separator}\"", 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..31922eb161 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Finns", "Re-import": "Importera igen", "Reading descriptors …": "Läser deskriptorer …", + "Re-import {register}?": "Importera {register} igen?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Vid ny import av {app} hämtas auktoriseringen för dess scheman från filen och ersätter alla ändringar som gjorts på den här instansen, och alla appens register kan importeras igen, inte bara {register}.", + "Authorization taken from the file over a local change: {count}.": "Auktorisering hämtad från filen över en lokal ändring: {count}.", "Register descriptors": "Registerdeskriptorer", "ships v{shipped}": "levererar v{shipped}", "State": "Status", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Id-kolumn", "Import JSON": "Importera JSON", "Imported \"{name}\".": "\"{name}\" har importerats.", + "Replaced \"{name}\".": "\"{name}\" har ersatts.", "Imports find the pack by this slug.": "Importer hittar paketet via denna slug.", "Join columns": "Sammanfoga kolumner", "Joined with {fields}, separated by \"{separator}\"": "Sammanfogad med {fields}, avgränsad med \"{separator}\"", 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..7e14a93d2e 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", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Mevcut", "Re-import": "Yeniden içe aktar", "Reading descriptors …": "Tanımlayıcılar okunuyor …", + "Re-import {register}?": "{register} yeniden içe aktarılsın mı?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "{app} uygulamasını yeniden içe aktarmak, şemalarının yetkilendirmesini dosyadan alır ve bu örnekte yapılan tüm değişikliklerin yerini alır; yalnızca {register} değil, uygulamanın tüm kayıtlarını da yeniden içe aktarabilir.", + "Authorization taken from the file over a local change: {count}.": "Yerel bir değişikliğin yerine dosyadan alınan yetkilendirme: {count}.", "Register descriptors": "Kayıt tanımlayıcıları", "ships v{shipped}": "v{shipped} sunuyor", "State": "Durum", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Kimlik sütunu", "Import JSON": "JSON içe aktar", "Imported \"{name}\".": "\"{name}\" içe aktarıldı.", + "Replaced \"{name}\".": "\"{name}\" değiştirildi.", "Imports find the pack by this slug.": "İçe aktarımlar paketi bu slug ile bulur.", "Join columns": "Sütunları birleştir", "Joined with {fields}, separated by \"{separator}\"": "{fields} ile birleştirildi, \"{separator}\" ile ayrıldı", 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..32590a3f90 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": "ИИ-чат", @@ -2762,6 +2762,9 @@ OC.L10N.register( "Present": "Наявний", "Re-import": "Імпортувати знову", "Reading descriptors …": "Читання дескрипторів …", + "Re-import {register}?": "Імпортувати {register} знову?", + "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Повторний імпорт {app} бере авторизацію його схем із файлу, замінюючи будь-які зміни, внесені на цьому екземплярі, і може повторно імпортувати всі реєстри застосунку, а не лише {register}.", + "Authorization taken from the file over a local change: {count}.": "Авторизація, взята з файлу замість локальної зміни: {count}.", "Register descriptors": "Дескриптори реєстрів", "ships v{shipped}": "постачає v{shipped}", "State": "Стан", @@ -3407,6 +3410,7 @@ OC.L10N.register( "Id column": "Стовпець ID", "Import JSON": "Імпортувати JSON", "Imported \"{name}\".": "\"{name}\" імпортовано.", + "Replaced \"{name}\".": "\"{name}\" замінено.", "Imports find the pack by this slug.": "Імпорти знаходять пакет за цим slug.", "Join columns": "Об'єднати стовпці", "Joined with {fields}, separated by \"{separator}\"": "Об'єднано з {fields}, розділено \"{separator}\"", 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/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 79cf329169..005e590330 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -3372,6 +3372,11 @@ private function registerEventListeners(IRegistrationContext $context): void { $context->registerEventListener(SchemaCreatedEvent::class, ApprovalChainAnnotationInstaller::class); $context->registerEventListener(SchemaUpdatedEvent::class, ApprovalChainAnnotationInstaller::class); + + // A schema written by anything but the schema API (the configuration + // importer, the import endpoint, the AI and MCP tools) re-checks the + // published forms that submit into it, as the API does itself. + $context->registerEventListener(SchemaUpdatedEvent::class, \OCA\OpenRegister\Listener\FormDestinationSchemaListener::class); $context->registerEventListener(ObjectUpdatingEvent::class, ApprovalChainGateListener::class); $context->registerEventListener( \OCA\OpenRegister\Event\TaskSequenceCompletedEvent::class, diff --git a/lib/BackgroundJob/CasePlanTimerRearmJob.php b/lib/BackgroundJob/CasePlanTimerRearmJob.php new file mode 100644 index 0000000000..44cbf2d9b3 --- /dev/null +++ b/lib/BackgroundJob/CasePlanTimerRearmJob.php @@ -0,0 +1,103 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Cron + * @package OCA\OpenRegister\BackgroundJob + * + * @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/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\BackgroundJob; + +use DateTimeImmutable; +use OCA\OpenRegister\Service\Case\CasePlanTimerRearm; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Runs one re-arm pass per tick. + * + * @SuppressWarnings(PHPMD.StaticAccess) DateTimeImmutable::createFromInterface + * is PHP's own conversion of the job clock. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ +class CasePlanTimerRearmJob extends TimedJob { + + /** + * The interval, the same as the timer sweep it backs up. + * + * @var int + */ + public const INTERVAL_SECONDS = 300; + + /** + * Constructor. + * + * @param ITimeFactory $time Job scheduling clock. + * @param CasePlanTimerRearm $rearm The pass. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + ITimeFactory $time, + private readonly CasePlanTimerRearm $rearm, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: self::INTERVAL_SECONDS); + + }//end __construct() + + /** + * Run one pass; a failing pass is logged, never thrown. + * + * @param mixed $argument The job argument (unused). + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) `$argument` is TimedJob's signature. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + protected function run($argument): void { + try { + $result = $this->rearm->run(now: DateTimeImmutable::createFromInterface($this->time->getDateTime())); + if ($result !== ['armed' => 0, 'enforced' => 0, 'dropped' => 0, 'failed' => 0]) { + $this->logger->info( + message: sprintf( + '[CasePlanTimerRearmJob] Armed %d statutory timer(s), enforced %d expired term(s), dropped %d marker(s); %d still failing', + $result['armed'], + $result['enforced'], + $result['dropped'], + $result['failed'] + ), + context: ['file' => __FILE__, 'line' => __LINE__] + $result + ); + } + } catch (Throwable $e) { + $this->logger->error( + message: '[CasePlanTimerRearmJob] Re-arm pass failed: ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'exception' => $e] + ); + }//end try + + }//end run() +}//end class diff --git a/lib/BackgroundJob/DraftExpiryJob.php b/lib/BackgroundJob/DraftExpiryJob.php index 9e7f6e04ce..8c95d2584f 100644 --- a/lib/BackgroundJob/DraftExpiryJob.php +++ b/lib/BackgroundJob/DraftExpiryJob.php @@ -112,17 +112,35 @@ protected function run(mixed $argument): void { // One line per schema that held an expired draft, so an administrator // reads from the log which schema lost how many (decision 228), and // one line with the totals, a zero included. + // + // A sweep that removed or refused a draft is logged at WARNING: the + // default loglevel (2 = warning) would hide an INFO line, and the first + // run after the upgrade removes drafts that were kept until then, so an + // administrator would have no record of how many. A sweep that changed + // nothing keeps its counted zero at info. foreach ($counts['perSchema'] as $row) { - $this->logger->info( - message: '[DraftExpiryJob] drafts past the lifetime of a schema', - context: $row - ); + $this->report(message: '[DraftExpiryJob] drafts past the lifetime of a schema', counts: $row); } unset($counts['perSchema']); - $this->logger->info( - message: '[DraftExpiryJob] swept drafts past their schema\'s lifetime', - context: $counts - ); + $this->report(message: '[DraftExpiryJob] swept drafts past their schema\'s lifetime', counts: $counts); }//end run() + + /** + * Log one line of counts: at warning when drafts were removed or refused, at info for a counted zero. + * + * @param string $message The line. + * @param array $counts The counts of one schema or of the whole sweep. + * + * @return void + */ + private function report(string $message, array $counts): void { + if (((int)($counts['removed'] ?? 0) + (int)($counts['refused'] ?? 0)) > 0) { + $this->logger->warning(message: $message, context: $counts); + + return; + } + + $this->logger->info(message: $message, context: $counts); + }//end report() }//end class diff --git a/lib/Command/ExampleSetInstallCommand.php b/lib/Command/ExampleSetInstallCommand.php index b9c1fa2670..be28991abe 100644 --- a/lib/Command/ExampleSetInstallCommand.php +++ b/lib/Command/ExampleSetInstallCommand.php @@ -83,7 +83,8 @@ protected function configure(): void { name: 'check', shortcut: null, mode: InputOption::VALUE_NONE, - description: 'Validate every object against its schema and write nothing' + description: 'Validate every object against its schema; writes no account, group or object ' + .'(a missing required register is still imported, and a missing cast organisation created)' ) ->addOption( name: 'adopt', @@ -185,6 +186,9 @@ private function check(array $plan, array $adopt, OutputInterface $output): int } $output->writeln('Every object passes its schema. No account, group or object of the set was written.'); + $output->writeln( + 'A required register the instance lacked was imported, and a cast organisation it lacked was created, because the check needs them.' + ); return self::SUCCESS; }//end check() @@ -227,7 +231,32 @@ private function printReport(array $report, OutputInterface $output): void { } } - foreach ($report['types'] as $type => $counts) { + $this->printTypes(types: $report['types'], output: $output); + + if ($report['files'] > 0) { + $output->writeln('Files attached: '.$report['files']); + } + + $this->printOutstanding(report: $report, output: $output); + + if ($report['passwords'] !== []) { + $output->writeln('New passwords, shown once and stored nowhere:'); + foreach ($report['passwords'] as $uid => $password) { + $output->writeln(' '.$uid.': '.$password); + } + } + }//end printReport() + + /** + * Print the counts per register and schema. + * + * @param array> $types Counts by ` `. + * @param OutputInterface $output The command output. + * + * @return void + */ + private function printTypes(array $types, OutputInterface $output): void { + foreach ($types as $type => $counts) { $output->writeln( sprintf( '%s: %d declared, %d created, %d already there, %d found afterwards', @@ -239,9 +268,19 @@ private function printReport(array $report, OutputInterface $output): void { ) ); } + }//end printTypes() - if ($report['files'] > 0) { - $output->writeln('Files attached: '.$report['files']); + /** + * Print what was left as it is, and what is not written, not there or not kept. + * + * @param array $report The installer's report. + * @param OutputInterface $output The command output. + * + * @return void + */ + private function printOutstanding(array $report, OutputInterface $output): void { + foreach ($report['left'] as $line) { + $output->writeln('Left as it is: '.$line); } $problems = ['Not written: ' => $report['dropped'], 'Not on the instance: ' => $report['missing'], 'Written but not kept: ' => $report['lost']]; @@ -250,14 +289,7 @@ private function printReport(array $report, OutputInterface $output): void { $output->writeln(''.$words.$line.''); } } - - if ($report['passwords'] !== []) { - $output->writeln('New passwords, shown once and stored nowhere:'); - foreach ($report['passwords'] as $uid => $password) { - $output->writeln(' '.$uid.': '.$password); - } - } - }//end printReport() + }//end printOutstanding() /** * The words for an outcome. diff --git a/lib/Command/ExampleSetRemoveCommand.php b/lib/Command/ExampleSetRemoveCommand.php index b3492829cb..cf655090dc 100644 --- a/lib/Command/ExampleSetRemoveCommand.php +++ b/lib/Command/ExampleSetRemoveCommand.php @@ -41,6 +41,13 @@ * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-remove-deletes-exactly-what-the-install-made-req-exs-007 */ class ExampleSetRemoveCommand extends Command { + /** + * How a `left` line that is a sentence of its own begins; any other line is an adopted account. + * + * @var array + */ + private const NOTES = ['app config ', 'user preference ', 'OpenRegister organisation ', 'change ']; + /** * Constructor. * @@ -90,7 +97,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } foreach ($report['left'] as $left) { - if (str_starts_with($left, 'app config ') === true || str_starts_with($left, 'user preference ') === true) { + if ($this->isNote(left: $left) === true) { $output->writeln('Left in place: '.$left.'.'); continue; } @@ -109,4 +116,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; }//end execute() + + /** + * Whether a `left` line is a sentence of its own and not an adopted account's id. + * + * @param string $left The line. + * + * @return bool + */ + private function isNote(string $left): bool { + foreach (self::NOTES as $prefix) { + if (str_starts_with($left, $prefix) === true) { + return true; + } + } + + return false; + }//end isNote() }//end class 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/Controller/MigrationPacksController.php b/lib/Controller/MigrationPacksController.php index ec415f806e..a9073e26ba 100644 --- a/lib/Controller/MigrationPacksController.php +++ b/lib/Controller/MigrationPacksController.php @@ -247,7 +247,10 @@ public function export(int $id): JSONResponse|DataDownloadResponse { * Import a migration pack definition from an uploaded JSON file. Admin only. * Upserts by the document's own `id` — re-importing an updated pack * updates the existing row. The log line says whether the import created - * or replaced a pack, as reported by the write itself. + * or replaced a pack, as reported by the write itself, and so does the + * status: 201 for a pack created, 200 for one replaced, so the caller can + * tell an administrator that a stored pack (possibly edited on this + * instance) was overwritten. * * @return JSONResponse * @@ -274,8 +277,10 @@ public function import(): JSONResponse { } $outcome = 'replaced'; + $status = 200; if ($result->created === true) { $outcome = 'created'; + $status = 201; } $pack = $result->pack; @@ -290,7 +295,7 @@ public function import(): JSONResponse { ] ); - return new JSONResponse(data: $pack->jsonSerialize(), statusCode: 201); + return new JSONResponse(data: $pack->jsonSerialize(), statusCode: $status); }//end import() /** diff --git a/lib/Controller/ObjectDraftController.php b/lib/Controller/ObjectDraftController.php index a789640830..c0913ab6bb 100644 --- a/lib/Controller/ObjectDraftController.php +++ b/lib/Controller/ObjectDraftController.php @@ -179,7 +179,14 @@ public function activate(string $register, string $schema, string $id): JSONResp ); }//end try - return new JSONResponse(data: $activated->jsonSerialize()); + // The saved entity goes through the render boundary the object API reads + // through: a `writeOnly` property is "may be sent, never returned", a + // property the caller's groups may not read is not theirs, and an + // encrypted one comes back decrypted for who may read it. The save + // renders its result already, but an automatic transition the save + // applied hands back an entity of its own, so it is rendered here once + // more: rendering is idempotent. + return new JSONResponse(data: $this->objects->renderEntity(entity: $activated, _rbac: true)); }//end activate() /** diff --git a/lib/Controller/ObjectFolderController.php b/lib/Controller/ObjectFolderController.php index 6e9aa6e127..4b9714b2b7 100644 --- a/lib/Controller/ObjectFolderController.php +++ b/lib/Controller/ObjectFolderController.php @@ -25,10 +25,12 @@ use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Exception\ObjectFileAccessDeniedException; +use OCA\OpenRegister\Exception\ObjectStateWriteException; use OCA\OpenRegister\Service\File\ObjectFileAccess; use OCA\OpenRegister\Service\File\ObjectFolderBrowser; use OCA\OpenRegister\Service\File\ObjectFolderResponder; use OCA\OpenRegister\Service\File\ObjectFolderUploads; +use OCA\OpenRegister\Service\Object\FileWriteGuard; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; @@ -43,7 +45,8 @@ * person's Files app, so a reader has no WebDAV path to them. These endpoints * are that path: every call checks the object first, as the signed-in person, * exactly as the object file endpoints do. A listing needs read on the object - * and answers 404 without it; a change needs update and answers 403 to a reader. + * and answers 404 without it; a change needs update and answers 403 to a reader, + * and 409 when the object is frozen or archived (REQ-OAS-007). * No Nextcloud share is created, because a share on an object folder is an * object grant that would outlive the rule that admitted the reader. * @@ -62,6 +65,7 @@ class ObjectFolderController extends Controller { * @param ObjectFolderBrowser $browser The folder tree of one object. * @param ObjectFolderUploads $uploads Stores a batch of uploads. * @param ObjectFolderResponder $responder Turns outcomes and failures into responses. + * @param FileWriteGuard $fileWriteGuard Refuses a change to the files of a frozen or archived object. */ public function __construct( string $appName, @@ -70,6 +74,7 @@ public function __construct( private readonly ObjectFolderBrowser $browser, private readonly ObjectFolderUploads $uploads, private readonly ObjectFolderResponder $responder, + private readonly FileWriteGuard $fileWriteGuard, ) { parent::__construct(appName: $appName, request: $request); }//end __construct() @@ -246,9 +251,19 @@ private function ensureReadable(string $register, string $schema, string $id): O * @return ObjectEntity The object. * * @throws ObjectFileAccessDeniedException With 404 without read, 403 with read but without update. + * @throws ObjectStateWriteException When the object is frozen or archived (answered 409, REQ-OAS-007). + * + * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 */ private function ensureChangeable(string $register, string $schema, string $id): ObjectEntity { - return $this->objectFileAccess->changeable(register: $register, schema: $schema, id: $id); + $object = $this->objectFileAccess->changeable(register: $register, schema: $schema, id: $id); + + // Asked after the access rule, so a caller without access still gets + // 404 or 403 and learns nothing of the state; the same order the files + // API uses (FilesController::ensureObjectAccess()). + $this->fileWriteGuard->assertWritable(object: $object); + + return $object; }//end ensureChangeable() /** diff --git a/lib/Controller/ObjectRelationsController.php b/lib/Controller/ObjectRelationsController.php index 0942f0628b..b216bc7f09 100644 --- a/lib/Controller/ObjectRelationsController.php +++ b/lib/Controller/ObjectRelationsController.php @@ -35,6 +35,10 @@ use InvalidArgumentException; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\ObjectRelation; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Service\Audit\PurposeGuard; +use OCA\OpenRegister\Service\Audit\PurposeRefusedException; use OCA\OpenRegister\Service\Export\ExportGate; use OCA\OpenRegister\Service\Object\ObjectReadAccess; use OCA\OpenRegister\Service\ObjectService; @@ -45,6 +49,7 @@ use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; @@ -54,6 +59,8 @@ * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) A REST surface over the * relation row store, the graph walk and the object read it authorises against. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One action per route of the relation + * surface (rows, derive, graph, tree, export), each a short method that refuses first. * * @spec openspec/changes/relation-types-with-inverses/specs/referential-integrity/spec.md */ @@ -69,6 +76,10 @@ class ObjectRelationsController extends Controller { * @param ExportGate $exportGate The export verb, checked before the graph leaves. * @param ObjectReadAccess $access Resolves an object under the caller's own RBAC. * @param ObjectTreeService $trees The bounded walk along a schema's parent property. + * @param PurposeGuard $purposeGuard The doelbinding guard the object read runs, asked before a tree or a graph is answered. + * @param RegisterMapper $registerMapper Resolves an object's register for that guard. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor injection; each dependency is named above. * * @spec openspec/changes/relation-types-with-inverses/specs/referential-integrity/spec.md */ @@ -81,6 +92,8 @@ public function __construct( private readonly ExportGate $exportGate, private readonly ObjectReadAccess $access, private readonly ObjectTreeService $trees, + private readonly PurposeGuard $purposeGuard, + private readonly RegisterMapper $registerMapper, ) { parent::__construct(appName: $appName, request: $request); }//end __construct() @@ -477,6 +490,11 @@ public function graph(string $register, string $schema, string $id): JSONRespons return $this->access->notReadable(); } + $refusal = $this->purposeRefusal(object: $object); + if ($refusal !== null) { + return $refusal; + } + return new JSONResponse( data: $this->graphs->graph( rootUuid: (string)$object->getUuid(), @@ -503,12 +521,18 @@ public function graph(string $register, string $schema, string $id): JSONRespons */ #[NoAdminRequired] #[NoCSRFRequired] + #[UserRateLimit(limit: 60, period: 60)] public function tree(string $register, string $schema, string $id): JSONResponse { $object = $this->access->readable(register: $register, schema: $schema, id: $id); if ($object === null) { return $this->access->notReadable(); } + $refusal = $this->purposeRefusal(object: $object); + if ($refusal !== null) { + return $refusal; + } + $direction = (string)$this->request->getParam('direction', ObjectTreeService::DIRECTION_DESCENDANTS); $directions = [ ObjectTreeService::DIRECTION_DESCENDANTS, @@ -563,6 +587,11 @@ public function exportGraph(string $register, string $schema, string $id): DataD return $this->access->notReadable(); } + $purposeRefusal = $this->purposeRefusal(object: $object); + if ($purposeRefusal !== null) { + return $purposeRefusal; + } + // REQ-EXP-001: a relation graph as a CSV is the object's data leaving // the instance, so it is an export and is checked against the export // verb, not against the read the caller already passed above. A @@ -601,6 +630,58 @@ public function exportGraph(string $register, string $schema, string $id): DataD ); }//end exportGraph() + /** + * The refusal for a read that has to name a purpose and did not, or null. + * + * The doelbinding guard the object GET runs, asked after the read check so + * a refusal never confirms an object the caller may not see. The names and + * properties these routes answer for many objects at once are the same + * data the object GET would refuse, and the accepted purpose is what puts + * the read in the processing register. An object whose register or schema + * does not resolve is answered as not readable: the rule cannot be applied. + * + * @param ObjectEntity $object The object the route answers for, already read under the caller's RBAC. + * + * @return JSONResponse|null The refusal, or null when the read may go on. + * + * @spec openspec/changes/audit-trail-shipped-and-purpose-bound/specs/verwerkingsregister-api/spec.md + */ + private function purposeRefusal(ObjectEntity $object): ?JSONResponse { + $schema = $this->access->schemaOf(object: $object); + $register = $this->registerOf(object: $object); + if ($schema === null || $register === null) { + return $this->access->notReadable(); + } + + try { + $this->purposeGuard->enforce(register: $register, schema: $schema); + } catch (PurposeRefusedException $refusal) { + return new JSONResponse(data: $refusal->toResponseBody(), statusCode: $refusal->getStatusCode()); + } + + return null; + }//end purposeRefusal() + + /** + * The register an object belongs to, or null when it does not resolve. + * + * @param ObjectEntity $object The object. + * + * @return Register|null The register. + */ + private function registerOf(ObjectEntity $object): ?Register { + $registerId = $object->getRegister(); + if ($registerId === null || $registerId === '') { + return null; + } + + try { + return $this->registerMapper->find($registerId, _rbac: false, _multitenancy: false); + } catch (\Throwable $e) { + return null; + } + }//end registerOf() + /** * The depth this request asks for. * diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php index a9983c8c9c..9a7b8b425a 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,51 @@ 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 = null; + if ($config['_search'] !== null) { + $search = (string)$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, + ] + ); + } 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/lib/Controller/OrganisationController.php b/lib/Controller/OrganisationController.php index 2d70263c3b..001066476e 100644 --- a/lib/Controller/OrganisationController.php +++ b/lib/Controller/OrganisationController.php @@ -35,6 +35,7 @@ use OCA\OpenRegister\Db\OrganisationMapper; use OCA\OpenRegister\Db\TenantUsageMapper; use OCA\OpenRegister\Service\OrganisationService; +use OCA\OpenRegister\Service\Rbac\GrantChangeAuditor; use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights; use OCA\OpenRegister\Service\TenantLifecycleService; use OCP\AppFramework\Controller; @@ -115,6 +116,13 @@ class OrganisationController extends Controller { */ private IGroupManager $groupManager; + /** + * Records a change of who holds the special right `grant_manage`. + * + * @var GrantChangeAuditor|null + */ + private ?GrantChangeAuditor $grantAuditor; + /** * OrganisationController constructor * @@ -127,6 +135,9 @@ class OrganisationController extends Controller { * @param TenantUsageMapper $tenantUsageMapper Usage mapper * @param IUserSession $userSession User session for authorization checks * @param IGroupManager $groupManager Group manager for admin checks + * @param GrantChangeAuditor|null $grantAuditor Records a change of who holds the special right `grant_manage`. Null only in legacy fixtures. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor injection; each dependency is named above. * * @spec openspec/specs/tenant-lifecycle/spec.md */ @@ -140,8 +151,10 @@ public function __construct( TenantUsageMapper $tenantUsageMapper, IUserSession $userSession, IGroupManager $groupManager, + ?GrantChangeAuditor $grantAuditor = null, ) { parent::__construct(appName: $appName, request: $request); + $this->grantAuditor = $grantAuditor; $this->organisationService = $organisationService; $this->organisationMapper = $organisationMapper; $this->logger = $logger; @@ -712,6 +725,7 @@ public function update(string $uuid): JSONResponse { $organisation = $this->organisationMapper->findByUuid($uuid); $data = $this->extractRequestData(); + $grantManageBefore = $this->grantManageOf(organisation: $organisation); // A member may edit the organisation; only its owner or an administrator // may decide who manages the grants on its records. @@ -744,12 +758,43 @@ public function update(string $uuid): JSONResponse { return $parentUpdateResponse; } - return $this->saveAndReturnOrganisation(organisation: $organisation); + $response = $this->saveAndReturnOrganisation(organisation: $organisation); + + // Who holds the right to decide who reads a record is the one change + // here that must be on the trail, whoever made it. + $grantManageAfter = $this->grantManageOf(organisation: $organisation); + if ($grantManageAfter !== $grantManageBefore) { + $this->grantAuditor?->recordSpecialRight( + organisationUuid: $uuid, + before: $grantManageBefore, + after: $grantManageAfter + ); + } + + return $response; } catch (Exception $e) { return $this->handleUpdateError(uuid: $uuid, exception: $e); }//end try }//end update() + /** + * The groups that hold the special right `grant_manage` on an organisation's records. + * + * @param object $organisation Organisation entity. + * + * @return array The stored list, empty when there is none. + * + * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md + */ + private function grantManageOf(object $organisation): array { + $list = ($organisation->getAuthorization()[OrganisationSpecialRights::GRANT_MANAGE] ?? []); + if (is_array($list) === false) { + return []; + } + + return $list; + }//end grantManageOf() + /** * Patch organisation details (alias for update) * diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php index b49609331a..b27bf63636 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() @@ -1279,6 +1297,13 @@ private function applyUpdate(int $id, array $data): JSONResponse { // set to `refuse` blocks the save; otherwise the broken forms are // unpublished after the save, and the response lists them. $formAssessment = $this->assessDependentForms(existing: $existingSchema, data: $data); + if ($formAssessment !== null) { + // This request judges the forms itself (refusing or unpublishing + // after the write): the listener that covers every other schema + // writer must not do it a second time. + $this->formDependentsCheck()?->claim(schemaId: $id); + } + if ($formAssessment !== null && $formAssessment['refused'] === true) { return new JSONResponse( data: [ diff --git a/lib/Db/EntityRelationMapper.php b/lib/Db/EntityRelationMapper.php index c6b37cbd16..17e3a0d613 100644 --- a/lib/Db/EntityRelationMapper.php +++ b/lib/Db/EntityRelationMapper.php @@ -23,8 +23,10 @@ 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\ICompositeExpression; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; @@ -948,16 +950,60 @@ 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 + * + * @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): 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))); + $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.'); + } + } + $relation->setDecisionUuid($decisionUuid); 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.php b/lib/Db/MagicMapper.php index 2e802ec60a..4d5f739b69 100644 --- a/lib/Db/MagicMapper.php +++ b/lib/Db/MagicMapper.php @@ -555,6 +555,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, @@ -562,7 +572,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( @@ -580,13 +591,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, @@ -4686,7 +4690,25 @@ private function prepareObjectDataForTable(array $objectData, Register $register $schemaProperties = $schema->getProperties(); if (is_array($schemaProperties) === true) { + $columnRoles = new MagicMetadataColumn(); foreach (array_keys($schemaProperties) as $propertyName) { + // A property whose column is a metadata column (`_owner`, `_authorization`, + // `_status`, ...) never writes it: the loop above stamped that column from + // server-side values, and the client's value must not replace them. The + // read side already treats such a column as metadata. + if (is_string($propertyName) === true && $columnRoles->shadowsMetadata(key: $propertyName) === true) { + $this->logger->warning( + message: '[MagicMapper] A schema property is named like a metadata column; its value is not written', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'propertyName' => $propertyName, + 'schema' => $schema->getId(), + ] + ); + continue; + } + // Use array_key_exists to distinguish between:. // - Property exists with null value → include in prepared data (update DB to null). // - Property doesn't exist at all → skip (don't change DB value). @@ -6438,7 +6460,8 @@ public function findInRegisterSchemaTable( schema: $schema, _rbac: $_rbac, _multitenancy: $_multitenancy, - registerId: $register->getId() + registerId: $register->getId(), + tableName: $tableName ); } @@ -6795,7 +6818,8 @@ public function findAcrossAllMagicTables( schema: $schema, _rbac: $_rbac, _multitenancy: $_multitenancy, - registerId: $register?->getId() + registerId: $register?->getId(), + tableName: $tableName ); $accessResult = $accessQb->executeQuery(); @@ -7671,7 +7695,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; @@ -7926,16 +7951,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() /** @@ -10128,6 +10160,27 @@ public function findMultiple(array $ids): array { * @spec openspec/specs/schema-driven-read-coercion/spec.md */ public function filterReadableUuids(int $registerId, int $schemaId, array $uuids): array { + return ($this->readableUuidsOrNull(registerId: $registerId, schemaId: $schemaId, uuids: $uuids) ?? []); + }//end filterReadableUuids() + + /** + * The same filter as filterReadableUuids(), but a failure is told apart from "none". + * + * A caller that reports completeness (a tree that says which bound stopped + * it) must not read "read access could not be established" as "nothing is + * readable here": the first is an incomplete answer, the second is a + * complete one. Null means the check itself failed (and was logged); + * an empty array means it ran and the caller may read none of the uuids. + * + * @param int $registerId The register the table belongs to. + * @param int $schemaId The schema the table belongs to. + * @param array $uuids UUIDs to test, all expected in that table. + * + * @return array|null The readable subset, or null when read access could not be established. + * + * @spec openspec/specs/schema-driven-read-coercion/spec.md + */ + public function readableUuidsOrNull(int $registerId, int $schemaId, array $uuids): ?array { $uuids = array_values(array_unique(array_filter($uuids, fn ($uuid): bool => is_string($uuid) && $uuid !== ''))); if (empty($uuids) === true) { return []; @@ -10159,7 +10212,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(); @@ -10183,11 +10237,11 @@ public function filterReadableUuids(int $registerId, int $schemaId, array $uuids 'error' => $e->getMessage(), ] ); - return []; + return null; }//end try return $readable; - }//end filterReadableUuids() + }//end readableUuidsOrNull() /** * Find all objects for a given schema. diff --git a/lib/Db/MagicMapper/MagicBulkHandler.php b/lib/Db/MagicMapper/MagicBulkHandler.php index 4f92739035..9cddd93dd9 100644 --- a/lib/Db/MagicMapper/MagicBulkHandler.php +++ b/lib/Db/MagicMapper/MagicBulkHandler.php @@ -214,7 +214,10 @@ private function prepareObjectsForDynamicTable(array $objects, Register $registe // Properties can be at top level OR in 'object' key (structured format). $propertySource = $object['object'] ?? $object; foreach ($propertySource as $propertyName => $value) { - if ($columnRoles->skippedOnWrite(key: $propertyName, schemaProperties: ($schema->getProperties() ?? [])) === true) { + // A declared property that would write a metadata column is skipped + // with the undeclared `_` keys and `@self`: the metadata columns were + // stamped above and a client value must not replace them. + if ($columnRoles->skippedOnWrite(key: (string)$propertyName, schemaProperties: ($schema->getProperties() ?? [])) === true) { continue; } @@ -225,15 +228,10 @@ private function prepareObjectsForDynamicTable(array $objects, Register $registe // 1. PostgreSQL rejects empty strings for JSON columns // 2. Column types might differ from schema (e.g., table created with old schema) // 3. Empty strings are semantically equivalent to NULL for most use cases. - if ($value === '') { - $value = null; - } + $value = match ($value) { '' => null, default => $value }; // Convert complex values for database storage. - $preparedObject[$columnName] = $value; - if (is_array($value) === true || is_object($value) === true) { - $preparedObject[$columnName] = json_encode($value); - } + $preparedObject[$columnName] = match (true) { is_array($value), is_object($value) => json_encode($value), default => $value }; }//end foreach $prepared[] = $preparedObject; diff --git a/lib/Db/MagicMapper/MagicMetadataColumn.php b/lib/Db/MagicMapper/MagicMetadataColumn.php index b66b7ba52b..8f43ecfb9c 100644 --- a/lib/Db/MagicMapper/MagicMetadataColumn.php +++ b/lib/Db/MagicMapper/MagicMetadataColumn.php @@ -73,6 +73,11 @@ public function field(string $column, array $columnToPropertyMap): ?string { * Whether a key of an object being written is metadata or `@self`, not a property column. * * A key starting with the prefix is metadata unless the schema declares it as a property. + * A declared property whose column is a metadata column (`_owner`, `_authorization`, + * `_status`, and the spellings that sanitise to one, `_Owner` or `__owner_`) is skipped + * too: the writer stamps the metadata columns from server-side values, and a client + * value for a property of that name must never land in the same column. The read side + * applies the same rule in {@see self::field()}: the metadata column wins. * * @param string $key The object key. * @param array $schemaProperties The schema's properties. @@ -86,9 +91,51 @@ public function skippedOnWrite(string $key, array $schemaProperties): bool { return true; } - return str_starts_with($key, self::PREFIX) === true && isset($schemaProperties[$key]) === false; + if (str_starts_with($key, self::PREFIX) === false) { + return false; + } + + return isset($schemaProperties[$key]) === false || $this->shadowsMetadata(key: $key); }//end skippedOnWrite() + /** + * Whether the column a property key is written to is a metadata column. + * + * @param string $key The property key. + * + * @return bool True when writing the property would write a metadata column. + * + * @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001 + */ + public function shadowsMetadata(string $key): bool { + $column = $this->columnFor(key: $key); + if (str_starts_with($column, self::PREFIX) === false) { + return false; + } + + return $this->attribute(field: substr($column, strlen(self::PREFIX))) !== null; + }//end shadowsMetadata() + + /** + * The column a property key is written to, as the writers sanitise it. + * + * The same rule MagicMapper and MagicBulkHandler apply (camelCase to snake_case, + * anything else but letters, digits and underscores to an underscore, runs of + * underscores to one, trailing underscores dropped), so a key is judged by the + * column it would really write. + * + * @param string $key The property key. + * + * @return string The column name. + */ + private function columnFor(string $key): string { + $name = strtolower((string)preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $key)); + $name = (string)preg_replace('/[^a-z0-9_]/', '_', $name); + $name = (string)preg_replace('/_+/', '_', $name); + + return rtrim($name, '_'); + }//end columnFor() + /** * The ObjectEntity attribute a metadata field sets, or null when it has none. * diff --git a/lib/Db/MagicMapper/MagicRbacHandler.php b/lib/Db/MagicMapper/MagicRbacHandler.php index 5aeafe974d..175e8b2e54 100644 --- a/lib/Db/MagicMapper/MagicRbacHandler.php +++ b/lib/Db/MagicMapper/MagicRbacHandler.php @@ -130,6 +130,10 @@ 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. + * + * @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, @@ -141,6 +145,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 +333,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 +347,7 @@ private function reachableRowSqlFor( string $action, ?Schema $schema = null, string $columnPrefix = '', + ?string $tableName = null, ): string { $reachable = $this->objectScope()->notPrivateOrGrantedSql( authColumn: $columnName, @@ -354,14 +361,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 +531,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 +546,7 @@ public function applyRbacFilters( IQueryBuilder $qb, Schema $schema, string $action = 'read', + ?string $tableName = null, ): void { $user = $this->userSession->getUser(); $userId = $user?->getUID(); @@ -594,7 +624,8 @@ public function applyRbacFilters( userId: $userId, action: $action, schema: $schema, - columnPrefix: 't.' + columnPrefix: 't.', + tableName: $tableName ) ); @@ -1632,6 +1663,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 +1671,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 +1681,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 +1710,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 +1719,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 +1745,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 +1753,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 +1800,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 +1876,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 +1888,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 +1910,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 92eb3619c9..da081402f5 100644 --- a/lib/Db/MagicMapper/MagicSearchHandler.php +++ b/lib/Db/MagicMapper/MagicSearchHandler.php @@ -444,6 +444,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 * @@ -455,6 +457,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, @@ -471,7 +474,8 @@ public function applyAccessControlToQuery( _rbac: $_rbac, _multitenancy: $resolvedMultitenancy, multitenancyExplicit: false, - registerId: $registerId + registerId: $registerId, + tableName: $tableName ); }//end applyAccessControlToQuery() @@ -534,7 +538,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. @@ -805,7 +810,8 @@ public function buildWhereConditionsSql( schema: $schema, rbac: $_rbac, connection: $connection, - registerId: $registerId + registerId: $registerId, + tableName: $tableName ) ); @@ -1055,6 +1061,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. * @@ -1065,7 +1072,8 @@ private function boundaryConditionsSql( Schema $schema, mixed $rbac, mixed $connection, - ?int $registerId + ?int $registerId, + ?string $tableName = null ): array { $conditions = []; @@ -1100,7 +1108,7 @@ private function boundaryConditionsSql( } if ($rbac === true) { - $rbacCondition = $this->buildRbacConditionSql(schema: $schema); + $rbacCondition = $this->buildRbacConditionSql(schema: $schema, tableName: $tableName); if ($rbacCondition !== null) { $conditions[] = $rbacCondition; } @@ -1369,12 +1377,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. @@ -2485,6 +2494,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 * @@ -2497,6 +2507,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 @@ -2538,7 +2549,8 @@ private function applyAccessControlFilters( $this->rbacHandler->applyRbacFilters( qb: $qb, schema: $schema, - action: 'read' + action: 'read', + tableName: $tableName ); } }//end applyAccessControlFilters() diff --git a/lib/Db/TaskMapper.php b/lib/Db/TaskMapper.php index 29350484a9..94ddd79bc9 100644 --- a/lib/Db/TaskMapper.php +++ b/lib/Db/TaskMapper.php @@ -581,15 +581,19 @@ private function applyInboxPredicates(IQueryBuilder $qb, TaskInboxCriteria $crit * reason {@see applyExternalExclusion()} gives: that is the case looking at * its own work, and it must show what waits and on what. * + * The portal seam calls it without criteria: a resident's task list is the + * only inbox an external task has, so a task the case holds back waits out + * of sight there too, always. + * * @param IQueryBuilder $qb The query under construction. - * @param TaskInboxCriteria $criteria Carries the anchors and `includeBlocked`. + * @param TaskInboxCriteria|null $criteria Carries the anchors and `includeBlocked`; null for the portal seam. * * @return void * * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ - private function applyBlockedExclusion(IQueryBuilder $qb, TaskInboxCriteria $criteria): void { - if ($criteria->includeBlocked === true || $criteria->objectUuid !== null || $criteria->runUuid !== null) { + private function applyBlockedExclusion(IQueryBuilder $qb, ?TaskInboxCriteria $criteria = null): void { + if ($criteria !== null && ($criteria->includeBlocked === true || $criteria->objectUuid !== null || $criteria->runUuid !== null)) { return; } @@ -695,7 +699,7 @@ public function countOpenExternalForParty(string $partyReference): int { }//end countOpenExternalForParty() /** - * The portal seam's predicate: this party's, external, still open. + * The portal seam's predicate: this party's, external, still open, and not held back by an open blocker. * * @param IQueryBuilder $qb The query under construction. * @param string $partyReference The stored party reference. @@ -708,6 +712,7 @@ private function applyPartyPredicates(IQueryBuilder $qb, string $partyReference) $qb->andWhere($qb->expr()->eq('assignee', $qb->createNamedParameter($partyReference))); $qb->andWhere($qb->expr()->eq('performer_type', $qb->createNamedParameter(Task::PERFORMER_EXTERNAL))); $qb->andWhere($qb->expr()->eq('is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))); + $this->applyBlockedExclusion(qb: $qb); }//end applyPartyPredicates() /** 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/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/Exception/DraftAlreadyLeftException.php b/lib/Exception/DraftAlreadyLeftException.php new file mode 100644 index 0000000000..9cfca232ea --- /dev/null +++ b/lib/Exception/DraftAlreadyLeftException.php @@ -0,0 +1,36 @@ + + * 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/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +/** + * A promotion was allowed for an object that has already left draft. + * + * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft + */ +class DraftAlreadyLeftException extends ValidationException { +}//end class diff --git a/lib/Exception/ProviderUnavailableException.php b/lib/Exception/ProviderUnavailableException.php index 1bce3ba46d..bdee77c818 100644 --- a/lib/Exception/ProviderUnavailableException.php +++ b/lib/Exception/ProviderUnavailableException.php @@ -41,6 +41,7 @@ class ProviderUnavailableException extends \RuntimeException { public const CAUSE_OPENCONNECTOR_SOURCE_MISSING = 'openconnector-source-missing'; public const CAUSE_UPSTREAM_SERVICE_DOWN = 'upstream-service-down'; public const CAUSE_PROVIDER_AUTH = 'provider-auth'; + public const CAUSE_CONNECTOR_CANNOT_WITHHOLD_BODIES = 'connector-cannot-withhold-bodies'; /** * The cause classification. @@ -69,7 +70,8 @@ public function __construct(string $message, string $cause, ?\Throwable $previou * Return the cause classification. * * @return string One of openconnector-down | - * openconnector-source-missing | upstream-service-down. + * openconnector-source-missing | upstream-service-down | + * provider-auth | connector-cannot-withhold-bodies. * * @spec openspec/changes/retrofit-2026-05-24-b-exception-all/tasks.md#task-2 */ diff --git a/lib/Listener/DecisionTableValidationListener.php b/lib/Listener/DecisionTableValidationListener.php index 84f78844c6..f6baa8833f 100644 --- a/lib/Listener/DecisionTableValidationListener.php +++ b/lib/Listener/DecisionTableValidationListener.php @@ -33,6 +33,7 @@ use OCA\OpenRegister\Service\Dmn\DecisionTableStore; use OCA\OpenRegister\Service\Dmn\DecisionTableValidator; use OCA\OpenRegister\Service\ObjectService; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use Throwable; @@ -102,7 +103,17 @@ public function handle(Event $event): void { * @return void */ private function validate(ObjectCreatingEvent|ObjectUpdatingEvent $event, ObjectEntity $object): void { - if ($this->isDecisionTable(object: $object) === false) { + $isTable = $this->isDecisionTable(object: $object); + if ($isTable === false) { + return; + } + + if ($isTable === null) { + $this->refuse( + event: $event, + message: 'The object could not be checked against the decision-table rules, so it was not saved: its schema or register could not be read.' + ); + return; } @@ -112,32 +123,47 @@ private function validate(ObjectCreatingEvent|ObjectUpdatingEvent $event, Object } $problems = $this->validator->validate(table: $data); - $clash = $this->keyTakenBy(key: trim((string)($data['key'] ?? '')), uuid: (string)$object->getUuid()); - if ($clash !== null) { - $problems[] = sprintf('the key "%s" is already used by decision table %s', (string)$data['key'], $clash); + $key = trim((string)($data['key'] ?? '')); + $clash = $this->keyTakenBy(key: $key, uuid: (string)$object->getUuid()); + if ($clash === null) { + $problems[] = sprintf('the key "%s" could not be checked against the tables already stored', $key); + } elseif ($clash !== '') { + $problems[] = sprintf('the key "%s" is already used by decision table %s', $key, $clash); } if ($problems === []) { return; } - $event->setErrors( - [ - 'code' => self::ERROR_CODE, - 'message' => 'The decision table cannot be saved: ' . implode('; ', $problems) . '.', - ] - ); - $event->stopPropagation(); + $this->refuse(event: $event, message: 'The decision table cannot be saved: ' . implode('; ', $problems) . '.'); }//end validate() + /** + * Refuse the save. + * + * @param ObjectCreatingEvent|ObjectUpdatingEvent $event The save event. + * @param string $message Why. + * + * @return void + */ + private function refuse(ObjectCreatingEvent|ObjectUpdatingEvent $event, string $message): void { + $event->setErrors(['code' => self::ERROR_CODE, 'message' => $message]); + $event->stopPropagation(); + }//end refuse() + /** * Whether the object is a row of the decision-table schema. * + * A schema or register that does not exist means the object is not a + * decision table. One that could not be read (a database error) means it + * cannot be told, and that answer is null: the guard must not wave a table + * through because it could not look. + * * @param ObjectEntity $object The object being saved. * - * @return bool True for a decision table. + * @return bool|null True for a decision table, false for any other object, null when it cannot be told. */ - private function isDecisionTable(ObjectEntity $object): bool { + private function isDecisionTable(ObjectEntity $object): ?bool { $reference = $object->getSchema(); if ($reference === null || $reference === '') { return false; @@ -145,8 +171,10 @@ private function isDecisionTable(ObjectEntity $object): bool { try { $schema = $this->schemas->find($reference, _rbac: false, _multitenancy: false); - } catch (Throwable $unresolved) { + } catch (DoesNotExistException $unresolved) { return false; + } catch (Throwable $unreadable) { + return null; } if ($schema->getSlug() !== DecisionTableStore::SCHEMA) { @@ -164,9 +192,9 @@ private function isDecisionTable(ObjectEntity $object): bool { * * @param ObjectEntity $object The object being saved. * - * @return bool True when its register is `decision-tables`. + * @return bool|null True when its register is `decision-tables`, null when the register could not be read. */ - private function inDecisionTableRegister(ObjectEntity $object): bool { + private function inDecisionTableRegister(ObjectEntity $object): ?bool { $reference = $object->getRegister(); if ($reference === null || $reference === '') { return false; @@ -174,8 +202,10 @@ private function inDecisionTableRegister(ObjectEntity $object): bool { try { $register = $this->registers->find($reference, _rbac: false, _multitenancy: false); - } catch (Throwable $unresolved) { + } catch (DoesNotExistException $unresolved) { return false; + } catch (Throwable $unreadable) { + return null; } return $register->getSlug() === DecisionTableStore::REGISTER; @@ -187,11 +217,11 @@ private function inDecisionTableRegister(ObjectEntity $object): bool { * @param string $key The key being saved. * @param string $uuid The uuid of the row being saved. * - * @return string|null The other table's uuid, or null when the key is free. + * @return string|null The other table's uuid, '' when the key is free, null when the search could not run. */ private function keyTakenBy(string $key, string $uuid): ?string { if ($key === '') { - return null; + return ''; } try { @@ -214,6 +244,6 @@ private function keyTakenBy(string $key, string $uuid): ?string { } } - return null; + return ''; }//end keyTakenBy() }//end class diff --git a/lib/Listener/FormDestinationSchemaListener.php b/lib/Listener/FormDestinationSchemaListener.php new file mode 100644 index 0000000000..0391ba439e --- /dev/null +++ b/lib/Listener/FormDestinationSchemaListener.php @@ -0,0 +1,153 @@ + + * + * @category Listener + * @package OCA\OpenRegister\Listener + * + * @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/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Event\SchemaUpdatedEvent; +use OCA\OpenRegister\Service\Form\FormDependentsCheck; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Unpublishes the forms a schema write broke, for every writer but the schema API. + * + * @template-implements IEventListener + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ +class FormDestinationSchemaListener implements IEventListener { + + /** + * Constructor. + * + * @param ContainerInterface $container Resolves the check once a definition changed. + * @param LoggerInterface $logger Records a check that could not run and a refusal that could not be honoured. + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Judge the dependent forms of a schema whose definition changed. + * + * @param Event $event The event. + * + * @return void + * + * @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 handle(Event $event): void { + if ($event instanceof SchemaUpdatedEvent === false) { + return; + } + + $schema = $event->getNewSchema(); + if ($this->definitionChanged(new: $schema, old: $event->getOldSchema()) === false) { + return; + } + + try { + $check = $this->container->get(FormDependentsCheck::class); + if ($check instanceof FormDependentsCheck === false || $check->isClaimed(schemaId: (int)$schema->getId()) === true) { + return; + } + + $assessment = $check->assess(schema: $schema); + if ($assessment['affected'] === []) { + return; + } + + if ($assessment['refused'] === true) { + $this->logger->warning( + message: '[FormDestinationSchemaListener] An owning app set formDestinationBreak to refuse, but this schema write ' + . 'has no response to refuse; the broken forms are unpublished instead', + context: ['schema' => $assessment['schema'], 'forms' => count($assessment['affected'])] + ); + $assessment['refused'] = false; + } + + $check->apply(assessment: $assessment); + } catch (Throwable $e) { + // The write has happened. A check that fails must not undo it, but it must leave a trace. + $this->logger->warning( + message: '[FormDestinationSchemaListener] The published forms of a changed schema could not be re-checked', + context: ['schema' => (int)$schema->getId(), 'error' => $e->getMessage()] + ); + } + }//end handle() + + /** + * Whether a write changed what a form is judged against: the properties or the required list. + * + * Compared by content, not by key order, so a re-import that only reorders + * the keys judges no form. + * + * @param Schema $new The schema after the write. + * @param Schema $old The schema before it. + * + * @return bool True when properties or required differ. + */ + private function definitionChanged(Schema $new, Schema $old): bool { + return $this->canonical(value: ($new->getProperties() ?? [])) !== $this->canonical(value: ($old->getProperties() ?? [])) + || $this->canonical(value: ($new->getRequired() ?? [])) !== $this->canonical(value: ($old->getRequired() ?? [])); + }//end definitionChanged() + + /** + * A value with every map's keys in the same order, recursively. + * + * @param array $value The value. + * + * @return array The value, maps sorted by key. + */ + private function canonical(array $value): array { + foreach ($value as $key => $item) { + if (is_array($item) === true) { + $value[$key] = $this->canonical(value: $item); + } + } + + ksort($value); + + return $value; + }//end canonical() +}//end class diff --git a/lib/Listener/FrozenNodeWriteListener.php b/lib/Listener/FrozenNodeWriteListener.php index 6546168726..8ae8750b21 100644 --- a/lib/Listener/FrozenNodeWriteListener.php +++ b/lib/Listener/FrozenNodeWriteListener.php @@ -36,8 +36,10 @@ 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\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Throwable; @@ -50,12 +52,18 @@ * 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. + * asks for. The mapper that resolves the object is fetched from the container + * only at that point, so a node outside every object folder never builds it. * * Fail closed: when the lookup of a candidate owner FAILS (a database error, * anything but "no such object"), the write is refused and logged with the @@ -89,14 +97,19 @@ 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 ContainerInterface $container Resolves the MagicMapper (the owning object by its UUID) once a node + * has proved to be in an object folder: this listener sits on the four + * node events of every file operation on the instance, and the mapper's + * graph is wide. + * @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 ContainerInterface $container, private readonly LoggerInterface $logger, + private readonly IRootFolder $rootFolder, ) { }//end __construct() @@ -169,17 +182,59 @@ 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; } + $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( + $found = $this->container->get(MagicMapper::class)->findAcrossAllSources( identifier: $candidate['uuid'], _rbac: false, _multitenancy: false @@ -206,21 +261,80 @@ 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. + * + * 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. + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The flag names where the walk starts (the node or its parent), not a second behaviour. */ - 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/lib/Mcp/AttributeToolScanner.php b/lib/Mcp/AttributeToolScanner.php index 802071801f..ead9e5064d 100644 --- a/lib/Mcp/AttributeToolScanner.php +++ b/lib/Mcp/AttributeToolScanner.php @@ -66,6 +66,17 @@ * @package OCA\OpenRegister\Mcp */ final class AttributeToolScanner { + + /** + * The keys the MCP specification reserves for tool hints in a descriptor's + * `annotations` object. A client may auto-approve on them, so a free-form + * mark must not carry one: OpenRegister's own hint and grant checks decide + * what a tool is, and a mark is only a claim. + * + * @var list + */ + public const RESERVED_ANNOTATION_KEYS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint', 'title']; + /** * Scan every declared scannable class for one app and return every * attributed-method descriptor found across all of them. @@ -372,7 +383,11 @@ private function unrecognisedDeclaration(McpTool $attribute): ?array { $badAnnotation = $this->malformedAnnotation(annotations: $attribute->annotations); if ($badAnnotation !== null) { - return ['key' => 'annotations', 'value' => $badAnnotation, 'allowed' => ['non-empty string key => scalar value']]; + return [ + 'key' => 'annotations', + 'value' => $badAnnotation, + 'allowed' => ['non-empty string key => scalar value, other than the MCP hint keys ' . implode(', ', self::RESERVED_ANNOTATION_KEYS)], + ]; } return null; @@ -380,12 +395,18 @@ private function unrecognisedDeclaration(McpTool $attribute): ?array { /** * The first entry of a declared annotations map that is not a non-empty - * string key holding a scalar, or null when the whole map is well formed. + * string key holding a scalar, or that uses one of the MCP specification's + * reserved hint keys, or null when the whole map is well formed. * * The map is free-form on purpose (OpenRegister reads none of it), but * not shapeless: a nested array or an integer key would reach a consumer * as something it cannot compare against a mark, and the tool would - * silently never qualify. Refusing it at scan time names the mistake. + * silently never qualify. Refusing it at scan time names the mistake. The + * map is forwarded to external MCP clients under `annotations`, the key the + * specification uses for tool hints, so a hint key in it (`readOnlyHint` on + * a write tool) would be trusted by a client that auto-approves read-only + * tools while OpenRegister's own checks say otherwise; those keys are + * refused too, compared case-insensitively. * * @param array $annotations The declared map. * @@ -396,7 +417,7 @@ private function unrecognisedDeclaration(McpTool $attribute): ?array { */ private function malformedAnnotation(array $annotations): ?string { foreach ($annotations as $key => $value) { - if (is_string($key) === false || trim($key) === '' || is_scalar($value) === false) { + if (is_string($key) === false || trim($key) === '' || is_scalar($value) === false || $this->isReservedAnnotationKey(key: $key) === true) { return (string) $key; } } @@ -404,6 +425,20 @@ private function malformedAnnotation(array $annotations): ?string { return null; }//end malformedAnnotation() + /** + * Whether a key is one the MCP specification reserves for tool hints. + * + * @param string $key The annotation key. + * + * @return bool + * + * @spec openspec/specs/ai-mcp/spec.md + * (Requirement: REQ-ATTR-007 — A curated attribute tool forwards free-form annotations) + */ + private function isReservedAnnotationKey(string $key): bool { + return in_array(strtolower(trim($key)), array_map('strtolower', self::RESERVED_ANNOTATION_KEYS), true); + }//end isReservedAnnotationKey() + /** * Infer the `inputSchema` from the method's parameter type hints and * docblock `@param` tags. Parameters without a default value are 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/Repair/ImportDecisionTableRegister.php b/lib/Repair/ImportDecisionTableRegister.php index 0eb67a50b2..3d3da71dfc 100644 --- a/lib/Repair/ImportDecisionTableRegister.php +++ b/lib/Repair/ImportDecisionTableRegister.php @@ -30,6 +30,7 @@ namespace OCA\OpenRegister\Repair; +use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds; use OCA\OpenRegister\Service\ConfigurationService; use OCP\App\IAppManager; use OCP\Migration\IOutput; @@ -68,7 +69,7 @@ class ImportDecisionTableRegister implements IRepairStep { * * @var string */ - private const CONFIG_APP_ID = 'openregister.decision-tables'; + private const CONFIG_APP_ID = DescriptorConfigIds::DECISION_TABLES; /** * Constructor. diff --git a/lib/Repair/ImportDismissedPairRegister.php b/lib/Repair/ImportDismissedPairRegister.php index 427644c21e..dfd652f2c0 100644 --- a/lib/Repair/ImportDismissedPairRegister.php +++ b/lib/Repair/ImportDismissedPairRegister.php @@ -39,6 +39,7 @@ namespace OCA\OpenRegister\Repair; +use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds; use OCA\OpenRegister\Service\ConfigurationService; use OCP\App\IAppManager; use OCP\Migration\IOutput; @@ -75,7 +76,7 @@ class ImportDismissedPairRegister implements IRepairStep { * * @var string */ - private const CONFIG_APP_ID = 'openregister.dismissed-pair'; + private const CONFIG_APP_ID = DescriptorConfigIds::DISMISSED_PAIR; /** * Constructor. diff --git a/lib/Repair/ImportDsarRegisters.php b/lib/Repair/ImportDsarRegisters.php index b08b5f50fe..bf56b6d445 100644 --- a/lib/Repair/ImportDsarRegisters.php +++ b/lib/Repair/ImportDsarRegisters.php @@ -38,6 +38,7 @@ namespace OCA\OpenRegister\Repair; +use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds; use OCA\OpenRegister\Service\ConfigurationService; use OCP\App\IAppManager; use OCP\Migration\IOutput; @@ -70,11 +71,11 @@ class ImportDsarRegisters implements IRepairStep { */ private const REGISTERS = [ '/lib/Settings/data_subject_request_register.json' => [ - 'appId' => 'openregister.dsar-cases', + 'appId' => DescriptorConfigIds::DSAR_CASES, 'version' => '1.2.0', ], '/lib/Settings/dsar_policy_pack_register.json' => [ - 'appId' => 'openregister.dsar-policy-packs', + 'appId' => DescriptorConfigIds::DSAR_POLICY_PACKS, 'version' => '1.1.0', ], ]; diff --git a/lib/Repair/ImportEdepotTransferRegister.php b/lib/Repair/ImportEdepotTransferRegister.php index 9e4d188436..5ea8b9f746 100644 --- a/lib/Repair/ImportEdepotTransferRegister.php +++ b/lib/Repair/ImportEdepotTransferRegister.php @@ -32,6 +32,7 @@ namespace OCA\OpenRegister\Repair; +use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds; use OCA\OpenRegister\Service\ConfigurationService; use OCP\App\IAppManager; use OCP\Migration\IOutput; @@ -69,7 +70,7 @@ class ImportEdepotTransferRegister implements IRepairStep { * * @var string */ - private const CONFIG_APP_ID = 'openregister.edepot-transfers'; + private const CONFIG_APP_ID = DescriptorConfigIds::EDEPOT_TRANSFERS; /** * Constructor. diff --git a/lib/Repair/ImportMergeOperationRegister.php b/lib/Repair/ImportMergeOperationRegister.php index 41bcb71060..cee8eac142 100644 --- a/lib/Repair/ImportMergeOperationRegister.php +++ b/lib/Repair/ImportMergeOperationRegister.php @@ -46,6 +46,7 @@ namespace OCA\OpenRegister\Repair; +use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds; use OCA\OpenRegister\Service\ConfigurationService; use OCP\App\IAppManager; use OCP\Migration\IOutput; @@ -82,7 +83,7 @@ class ImportMergeOperationRegister implements IRepairStep { * * @var string */ - private const CONFIG_APP_ID = 'openregister.merge-operation'; + private const CONFIG_APP_ID = DescriptorConfigIds::MERGE_OPERATION; /** * Constructor. diff --git a/lib/Repair/ImportTrustConfigurationRegister.php b/lib/Repair/ImportTrustConfigurationRegister.php index 1931e70fc2..dc25e920fe 100644 --- a/lib/Repair/ImportTrustConfigurationRegister.php +++ b/lib/Repair/ImportTrustConfigurationRegister.php @@ -38,6 +38,7 @@ namespace OCA\OpenRegister\Repair; +use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds; use OCA\OpenRegister\Service\ConfigurationService; use OCP\App\IAppManager; use OCP\Migration\IOutput; @@ -72,7 +73,7 @@ class ImportTrustConfigurationRegister implements IRepairStep { * * @var string */ - private const CONFIG_APP_ID = 'openregister.trust-configuration'; + private const CONFIG_APP_ID = DescriptorConfigIds::TRUST_CONFIGURATION; /** * Constructor. diff --git a/lib/Repair/SeedVocabularyRegister.php b/lib/Repair/SeedVocabularyRegister.php index ae45c23bd5..c1cd465bc8 100644 --- a/lib/Repair/SeedVocabularyRegister.php +++ b/lib/Repair/SeedVocabularyRegister.php @@ -35,6 +35,7 @@ namespace OCA\OpenRegister\Repair; +use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds; use OCA\OpenRegister\Service\ConfigurationService; use OCA\OpenRegister\Service\SystemOperationContext; use OCA\OpenRegister\Service\VocabularyImportService; @@ -77,7 +78,7 @@ class SeedVocabularyRegister implements IRepairStep { * * @var string */ - private const CONFIG_APP_ID = 'openregister.vocabulary'; + private const CONFIG_APP_ID = DescriptorConfigIds::VOCABULARY; /** * App-relative paths of the bundled TOOI SKOS/JSON-LD seed fixtures. diff --git a/lib/Service/Aggregation/AggregationRunner.php b/lib/Service/Aggregation/AggregationRunner.php index 21b181592b..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. @@ -2640,7 +2669,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/Audit/HistoryEntryRedactor.php b/lib/Service/Audit/HistoryEntryRedactor.php index d2d51bc884..cebc2d4932 100644 --- a/lib/Service/Audit/HistoryEntryRedactor.php +++ b/lib/Service/Audit/HistoryEntryRedactor.php @@ -161,6 +161,10 @@ private function filterChanged(array $changed, Schema $schema, array $hidden): a $changed = $this->stripNestedWriteOnly(changed: $changed, schema: $schema); + if (is_array($changed['cascadeContext'] ?? null) === true) { + $changed['cascadeContext'] = $this->filterCascade(cascade: $changed['cascadeContext'], hidden: $hidden); + } + if (is_array($changed['@self'] ?? null) === false) { return $changed; } @@ -178,6 +182,32 @@ private function filterChanged(array $changed, Schema $schema, array $hidden): a return $changed; }//end filterChanged() + /** + * The cascade context of a referential-integrity entry, as a reader is given it. + * + * The context names the object whose delete cascaded into this one + * (`triggerObject`, `triggerSchema`), and that object may be one the reader + * may not read: the object read path would not tell them it exists. They are + * withheld for every reader; the kind of cascade stays, and the property that + * carried the reference stays unless the reader may not see that property. + * + * @param array $cascade The `cascadeContext` of one entry. + * @param array $hidden Withheld property names. + * + * @return array The context without the other object's identity. + */ + private function filterCascade(array $cascade, array $hidden): array { + $cascade['triggerObject'] = null; + $cascade['triggerSchema'] = null; + + $property = ($cascade['property'] ?? null); + if (is_string($property) === true && in_array(explode('.', $property)[0], $hidden, true) === true) { + $cascade['property'] = null; + } + + return $cascade; + }//end filterCascade() + /** * Strip declared nested write-only paths from the old and new values. * diff --git a/lib/Service/Calculation/RateSchedule.php b/lib/Service/Calculation/RateSchedule.php index 17b573cd2c..efd30a76c8 100644 --- a/lib/Service/Calculation/RateSchedule.php +++ b/lib/Service/Calculation/RateSchedule.php @@ -134,11 +134,14 @@ public function accrued(int|float $quantity, array $bands, int|float|null $cap): * @spec openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md */ public function rateAt(int|float $unit, array $bands): int|float|null { + // The schedule is checked whatever the unit is: a malformed one must raise an evaluation error and never be read as + // an amount, and `graduated` already refuses it at any quantity. + $checked = $this->normalise(bands: $bands); if ($unit <= 0) { return null; } - foreach ($this->normalise(bands: $bands) as $band) { + foreach ($checked as $band) { if ($band['upTo'] === null || $unit <= $band['upTo']) { return $band['rate']; } diff --git a/lib/Service/Case/CasePlanEnsurer.php b/lib/Service/Case/CasePlanEnsurer.php index da7472448f..85fcd19e41 100644 --- a/lib/Service/Case/CasePlanEnsurer.php +++ b/lib/Service/Case/CasePlanEnsurer.php @@ -44,6 +44,9 @@ /** * Ensures plan items with recorded states, idempotently. * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One verb whose steps are each a small check (states, history, anchor, + * insert, post-commit arming); the total sums them, no single method is complex. + * * @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states */ class CasePlanEnsurer { @@ -56,6 +59,8 @@ class CasePlanEnsurer { * @param CasePlanDefinition $definitions The one definition validator. * @param CasePlanTransitions $transitions The one lifecycle table. * @param IDBConnection $db Holds the transaction. + * @param CasePlanItemTimers|null $timers Arms the due and expiry timers of an item ensured as active: no transition + * event is dispatched for it, so the listener that arms them never hears of it. */ public function __construct( private readonly CaseItemMapper $items, @@ -63,6 +68,7 @@ public function __construct( private readonly CasePlanDefinition $definitions, private readonly CasePlanTransitions $transitions, private readonly IDBConnection $db, + private readonly ?CasePlanItemTimers $timers = null, ) { }//end __construct() @@ -135,13 +141,49 @@ public function ensure( throw $failure; } + $rows = $this->items->findByObject(objectUuid: $objectUuid); + $this->armTimers(rows: $rows, created: $created, actor: $actor); + return [ 'created' => $created, 'existing' => $kept, - 'items' => array_map(static fn (CaseItem $row): array => $row->jsonSerialize(), $this->items->findByObject(objectUuid: $objectUuid)), + 'items' => array_map(static fn (CaseItem $row): array => $row->jsonSerialize(), $rows), ]; }//end ensure() + /** + * Arm the deadlines of the items this call created as active. + * + * An item inserted in a state it carries goes through no state machine, so + * no `CaseItemTransitionedEvent` reaches the timer listener, and an + * item that is active with a `dueAt` or `expiresAt` (a statutory one + * above all) would never get its timer. Done after the commit, like the + * listener: a refused arm is logged and, for a statutory expiry, marked + * for the re-arm pass, and never undoes the ensure. An item that already + * existed is not touched: its timers were armed when it became active. + * + * @param array $rows Every row of the object, after the commit. + * @param array $created The keys this call created. + * @param string $actor The ensuring identity. + * + * @return void + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + private function armTimers(array $rows, array $created, string $actor): void { + if ($this->timers === null) { + return; + } + + $entered = array_filter( + $rows, + static fn (CaseItem $row): bool => $row->getState() === CaseItem::STATE_ACTIVE && in_array((string)$row->getItemKey(), $created, true) + ); + foreach ($entered as $row) { + $this->timers->onEntered(item: $row, actor: $actor); + } + }//end armTimers() + /** * Every node's state must be one its type can reach from `available`. * diff --git a/lib/Service/Case/CasePlanItemTimers.php b/lib/Service/Case/CasePlanItemTimers.php index 34ce0f3b86..73522436aa 100644 --- a/lib/Service/Case/CasePlanItemTimers.php +++ b/lib/Service/Case/CasePlanItemTimers.php @@ -10,7 +10,11 @@ * core's own rule for an enforcing outcome. Reaching a terminal state * cancels the item's open timers. The core decides WHEN; this class only * decides what a deadline means for an item. Nothing here ever blocks a - * transition: a refused arm or cancel is logged. + * transition: a refused arm or cancel is logged. A refused arm of a statutory + * (`wettelijk`) expiry is also marked ({@see UnarmedTimerMarkers}), because an + * item that is active past a legal term with nothing to end it is not a log + * line to move on from: {@see CasePlanTimerRearm} arms it again, or enforces + * the expiry when the term has passed meanwhile. * * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 @@ -63,15 +67,28 @@ class CasePlanItemTimers { */ private const ACTOR = 'case-plan'; + /** + * What {@see rearmStatutoryExpiry()} did. + */ + public const REARM_ARMED = 'armed'; + + public const REARM_OVERDUE = 'overdue'; + + public const REARM_NONE = 'none'; + + public const REARM_FAILED = 'failed'; + /** * Constructor. * * @param FlowTimerService $timers The one clock. * @param LoggerInterface $logger Failure reporting. + * @param UnarmedTimerMarkers|null $markers Where an item whose statutory expiry could not be armed is remembered. */ public function __construct( private readonly FlowTimerService $timers, private readonly LoggerInterface $logger, + private readonly ?UnarmedTimerMarkers $markers = null, ) { }//end __construct() @@ -104,6 +121,40 @@ public function onEntered(CaseItem $item, ?string $actor, ?DateTimeInterface $no return $armed; }//end onEntered() + /** + * Arm the statutory expiry of an item that is active without one, again. + * + * Called by {@see CasePlanTimerRearm} for an item the first arm failed + * for. Nothing is armed for an item that has no statutory expiry, and + * nothing for one whose term has already passed: that one is reported as + * overdue for the caller to enforce. + * + * @param CaseItem $item The item. + * @param DateTimeInterface $now The clock. + * @param string|null $actor The arming identity. + * + * @return string One of the REARM_* constants. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + public function rearmStatutoryExpiry(CaseItem $item, DateTimeInterface $now, ?string $actor = null): string { + $expires = $item->getExpiresAt(); + if ($expires === null || $this->legalEffect(item: $item, purpose: FlowTimer::PURPOSE_EXPIRY) !== FlowTimer::LEGAL_WETTELIJK) { + return self::REARM_NONE; + } + + $moment = DateTimeImmutable::createFromInterface($now); + if ($expires->getTimestamp() <= $moment->getTimestamp()) { + return self::REARM_OVERDUE; + } + + if ($this->arm(item: $item, purpose: FlowTimer::PURPOSE_EXPIRY, deadline: $expires, actor: $actor, now: $moment) === true) { + return self::REARM_ARMED; + } + + return self::REARM_FAILED; + }//end rearmStatutoryExpiry() + /** * Cancel the open timers of an item that reached a terminal state. * @@ -215,6 +266,35 @@ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadlin return true; } catch (Throwable $failure) { + if ($config['legalEffect'] === FlowTimer::LEGAL_WETTELIJK) { + // The transition is already committed and the caller swallows an + // exception raised from here (CasePlanStateMachine::flushEvents()), + // so this cannot fail it. What it can do is say loudly that the item + // is active without the timer that enforces its statutory expiry, and + // leave a durable marker the re-arm job acts on. + $this->mark(item: $item); + $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 until the re-arm pass arms it. %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, + ] + ); + + return false; + } + $this->logger->warning( '[CasePlanItemTimers] A plan-item timer was refused; the transition stands: ' . $failure->getMessage(), ['item' => $item->getUuid(), 'purpose' => $purpose, 'exception' => $failure] @@ -224,6 +304,24 @@ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadlin } }//end arm() + /** + * Remember that an item lacks its statutory expiry timer. + * + * @param CaseItem $item The item. + * + * @return void + */ + private function mark(CaseItem $item): void { + if ($this->markers === null || $this->markers->add(itemUuid: (string)$item->getUuid()) === true) { + return; + } + + $this->logger->error( + '[CasePlanItemTimers] The marker of an item without its statutory timer could not be stored; nothing will arm it again.', + ['item' => $item->getUuid(), 'case' => $item->getObjectUuid()] + ); + }//end mark() + /** * A due date advises; an expiry enforces only on a statutory item and * otherwise counts as a service norm. diff --git a/lib/Service/Case/CasePlanService.php b/lib/Service/Case/CasePlanService.php index 356a2acf21..4808f4c583 100644 --- a/lib/Service/Case/CasePlanService.php +++ b/lib/Service/Case/CasePlanService.php @@ -68,6 +68,11 @@ */ class CasePlanService { + /** + * What an in-process caller's actor id starts with (`system:`): a label for the audit, not a Nextcloud account. + */ + public const SYSTEM_ACTOR_PREFIX = 'system:'; + /** * Constructor. * @@ -84,6 +89,8 @@ class CasePlanService { * @param LoggerInterface $logger Failure reporting. * @param CasePlanEnsurer|null $ensurer Convergent bring-over; built from the * same collaborators when not injected. + * @param CasePlanItemTimers|null $timers Arms the deadlines of items the ensurer creates as active, when it is + * built here. */ public function __construct( private readonly CaseItemMapper $items, @@ -98,6 +105,7 @@ public function __construct( private readonly IDBConnection $db, private readonly LoggerInterface $logger, private ?CasePlanEnsurer $ensurer = null, + private readonly ?CasePlanItemTimers $timers = null, ) { }//end __construct() @@ -252,7 +260,8 @@ public function ensureItems(string $objectUuid, ?int $registerId, ?int $schemaId audits: $this->audits, definitions: $this->definitions, transitions: new CasePlanTransitions(), - db: $this->db + db: $this->db, + timers: $this->timers ); } @@ -769,7 +778,7 @@ private function systemActor(string $app): string { ); } - return 'system:' . $app; + return self::SYSTEM_ACTOR_PREFIX . $app; }//end systemActor() /** diff --git a/lib/Service/Case/CasePlanTimerRearm.php b/lib/Service/Case/CasePlanTimerRearm.php new file mode 100644 index 0000000000..77aec77136 --- /dev/null +++ b/lib/Service/Case/CasePlanTimerRearm.php @@ -0,0 +1,170 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @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/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use DateTimeImmutable; +use DateTimeInterface; +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\CaseItemMapper; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\FlowTimerMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The re-arm pass over the marked plan items. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ +class CasePlanTimerRearm { + + /** + * The cause reference an enforced expiry records instead of a timer uuid. + */ + public const CAUSE_REF = 'rearm-pass'; + + /** + * Constructor. + * + * @param UnarmedTimerMarkers $markers The items that lack their statutory timer. + * @param CaseItemMapper $items The plan-item table. + * @param FlowTimerMapper $timerRows Reads the timers an item already has. + * @param CasePlanItemTimers $timers Arms the timer. + * @param CasePlanService $plans Enforces an expiry that passed meanwhile. + * @param LoggerInterface $logger Failure reporting. + */ + public function __construct( + private readonly UnarmedTimerMarkers $markers, + private readonly CaseItemMapper $items, + private readonly FlowTimerMapper $timerRows, + private readonly CasePlanItemTimers $timers, + private readonly CasePlanService $plans, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Run one pass. + * + * @param DateTimeInterface|null $now The clock; null is the real clock. + * + * @return array{armed: int, enforced: int, dropped: int, failed: int} Work performed. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + public function run(?DateTimeInterface $now = null): array { + $moment = DateTimeImmutable::createFromInterface($now ?? new DateTimeImmutable()); + $result = ['armed' => 0, 'enforced' => 0, 'dropped' => 0, 'failed' => 0]; + foreach (array_keys($this->markers->all()) as $uuid) { + try { + $outcome = $this->rearm(itemUuid: (string)$uuid, now: $moment); + } catch (Throwable $failure) { + $outcome = 'failed'; + $this->logger->error( + '[CasePlanTimerRearm] Plan item ' . $uuid . ' still lacks its statutory expiry timer: ' . $failure->getMessage(), + ['item' => $uuid, 'exception' => $failure] + ); + } + + $result[$outcome]++; + } + + return $result; + }//end run() + + /** + * Settle one marked item. + * + * @param string $itemUuid The item. + * @param DateTimeImmutable $now The clock. + * + * @return string `armed`, `enforced`, `dropped` or `failed`. + */ + private function rearm(string $itemUuid, DateTimeImmutable $now): string { + try { + $item = $this->items->findByUuid(uuid: $itemUuid); + } catch (DoesNotExistException) { + $this->markers->remove(itemUuid: $itemUuid); + + return 'dropped'; + } + + if ($item->getState() !== CaseItem::STATE_ACTIVE || $this->hasOpenExpiry(item: $item) === true) { + $this->markers->remove(itemUuid: $itemUuid); + + return 'dropped'; + } + + $outcome = $this->timers->rearmStatutoryExpiry(item: $item, now: $now); + if ($outcome === CasePlanItemTimers::REARM_FAILED) { + return 'failed'; + } + + if ($outcome === CasePlanItemTimers::REARM_OVERDUE) { + // The term ran out while nothing was watching it: end the item as the fired timer would have. + $this->plans->onTimerExpired(itemUuid: $itemUuid, timerUuid: self::CAUSE_REF); + $this->markers->remove(itemUuid: $itemUuid); + + return 'enforced'; + } + + $this->markers->remove(itemUuid: $itemUuid); + if ($outcome === CasePlanItemTimers::REARM_ARMED) { + return 'armed'; + } + + return 'dropped'; + }//end rearm() + + /** + * Whether the item already has an expiry timer that is still running or suspended. + * + * @param CaseItem $item The item. + * + * @return bool + */ + private function hasOpenExpiry(CaseItem $item): bool { + $open = $this->timerRows->findBySubject( + subjectType: CasePlanItemTimers::SUBJECT_TYPE, + subjectUuid: (string)$item->getUuid(), + states: [FlowTimer::STATE_ARMED, FlowTimer::STATE_SUSPENDED] + ); + foreach ($open as $timer) { + if ($timer->getPurpose() === FlowTimer::PURPOSE_EXPIRY) { + return true; + } + } + + return false; + }//end hasOpenExpiry() +}//end class diff --git a/lib/Service/Case/CaseRealisationService.php b/lib/Service/Case/CaseRealisationService.php index b9332700a4..2a172efd46 100644 --- a/lib/Service/Case/CaseRealisationService.php +++ b/lib/Service/Case/CaseRealisationService.php @@ -100,6 +100,14 @@ public function realise(CaseItem $item, string $actor): void { $flow = trim((string)($item->getPlanSettings()['flows'][(string)$item->getItemKey()] ?? '')); $runnable = in_array($item->getPlanItemType(), [CaseItem::TYPE_STAGE, CaseItem::TYPE_PROCESS_TASK], true); if ($runnable === true && $flow !== '') { + // An in-process caller's `system:` is a label for the audit, not an account. As the run's user it + // would become the run's `runAs`, and every scoped step would be refused ("not a user account"), so the + // run is queued without a caller and the flow's own attribution (or its declared runAs) decides. + $user = $actor; + if (str_starts_with($actor, CasePlanService::SYSTEM_ACTOR_PREFIX) === true) { + $user = null; + } + $run = $this->runs->queue( flowId: $flow, subject: [ @@ -109,7 +117,7 @@ public function realise(CaseItem $item, string $actor): void { ], trigger: self::RUN_TRIGGER, context: ['caseItem' => (string)$item->getUuid()], - user: $actor + user: $user ); $item->setRealisationKind(CaseItem::REALISATION_RUN); $item->setRealisationUuid($run->getUuid()); diff --git a/lib/Service/Case/Nodes/CaseAdvanceNode.php b/lib/Service/Case/Nodes/CaseAdvanceNode.php index 85990ca4fa..416b78ff40 100644 --- a/lib/Service/Case/Nodes/CaseAdvanceNode.php +++ b/lib/Service/Case/Nodes/CaseAdvanceNode.php @@ -214,11 +214,8 @@ public function execute(array $items, array $config, array $context): array { */ private function advance(string $objectUuid, string $key, string $to, string $uid, ?string $reason): array { $plan = $this->plans->getPlan(objectUuid: $objectUuid, uid: $uid); - foreach (($plan['items'] ?? []) as $row) { - if (($row['key'] ?? null) !== $key) { - continue; - } - + $row = $this->liveRealisation(items: (array)($plan['items'] ?? []), key: $key); + if ($row !== null) { $moved = $this->plans->transition(itemUuid: (string)$row['uuid'], to: $to, uid: $uid, reason: $reason); return ['item' => $key, 'state' => (string)$moved->getState()]; @@ -228,4 +225,52 @@ private function advance(string $objectUuid, string $key, string $to, string $ui $this->l10n->t('The case plan on object %1$s has no item "%2$s".', [$objectUuid, $key]) ); }//end advance() + + /** + * The row of a key that can still move: a repeating item has one row per + * realisation (same key, `realisationCount` + 1), and the first row in plan + * order is the oldest, which is already completed or terminated. A row + * that is not terminal wins (the newest of them, should several be open); + * when every realisation is terminal the newest is returned, so the step + * fails on the realisation that is actually current. + * + * @param array> $items The plan's rows. + * @param string $key The plan-item key. + * + * @return array|null The row, or null when the plan has no such key. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + private function liveRealisation(array $items, string $key): ?array { + $chosen = null; + foreach ($items as $row) { + if (($row['key'] ?? null) !== $key) { + continue; + } + + if ($chosen === null || $this->outranks(candidate: $row, current: $chosen) === true) { + $chosen = $row; + } + } + + return $chosen; + }//end liveRealisation() + + /** + * Whether a row of the same key is a better one to advance than the one held. + * + * @param array $candidate The row under consideration. + * @param array $current The row held so far. + * + * @return bool True when the candidate is open and the held row is not, or both are alike and the candidate is a later realisation. + */ + private function outranks(array $candidate, array $current): bool { + $candidateOpen = (($candidate['isTerminal'] ?? false) === false); + $currentOpen = (($current['isTerminal'] ?? false) === false); + if ($candidateOpen !== $currentOpen) { + return $candidateOpen; + } + + return (int)($candidate['realisationCount'] ?? 1) > (int)($current['realisationCount'] ?? 1); + }//end outranks() }//end class diff --git a/lib/Service/Case/Nodes/CaseNodeBase.php b/lib/Service/Case/Nodes/CaseNodeBase.php index 87d36fe8fe..0a694132f6 100644 --- a/lib/Service/Case/Nodes/CaseNodeBase.php +++ b/lib/Service/Case/Nodes/CaseNodeBase.php @@ -145,7 +145,9 @@ protected function actingUid(array $context): string { /** * The distinct objects the step acts on, with the register and schema - * each names in its `@self` block, when numeric. + * the received item names in its `@self` block, when numeric and when the + * target is that item's own object (a configured `uuid` naming another + * object carries none). * * @param array $items The incoming items. * @param array $config The step configuration. @@ -168,7 +170,8 @@ protected function targets(array $items, array $config): array { $json = []; } - $uuid = trim((string)($json['uuid'] ?? ($json['id'] ?? ''))); + $own = trim((string)($json['uuid'] ?? ($json['id'] ?? ''))); + $uuid = $own; if (is_string($configured) === true && trim($configured) !== '') { $uuid = trim((string)FlowValueTemplate::render($configured, $json)); } @@ -182,16 +185,39 @@ protected function targets(array $items, array $config): array { $self = []; } + // The register and schema in `@self` belong to the RECEIVED item. A configured `uuid` may name another + // object (a parent, a related record); anchoring that object's plan to the item's register and schema + // would make a later ensure refuse the anchor and every read that filters on them miss the rows, so + // such a plan is anchored to neither. + $ownObject = ($uuid === $own); $targets[$uuid] = [ 'uuid' => $uuid, - 'register' => $this->intOrNull(value: ($self['register'] ?? null)), - 'schema' => $this->intOrNull(value: ($self['schema'] ?? null)), + 'register' => $this->anchorId(value: ($self['register'] ?? null), ownObject: $ownObject), + 'schema' => $this->anchorId(value: ($self['schema'] ?? null), ownObject: $ownObject), ]; }//end foreach return $targets; }//end targets() + /** + * The register or schema id to anchor a plan to: the item's own, only for the item's own object. + * + * @param mixed $value The `@self` value. + * @param bool $ownObject Whether the target is the object the item is. + * + * @return int|null The id, or null when it is not numeric or the target is another object. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case + */ + private function anchorId(mixed $value, bool $ownObject): ?int { + if ($ownObject === false) { + return null; + } + + return $this->intOrNull(value: $value); + }//end anchorId() + /** * A numeric id, or null. * diff --git a/lib/Service/Case/UnarmedTimerMarkers.php b/lib/Service/Case/UnarmedTimerMarkers.php new file mode 100644 index 0000000000..c13efaabfd --- /dev/null +++ b/lib/Service/Case/UnarmedTimerMarkers.php @@ -0,0 +1,155 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Case + * + * @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/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Case; + +use DateTimeImmutable; +use DateTimeInterface; +use OCA\OpenRegister\AppInfo\Application; +use OCP\IAppConfig; +use Throwable; + +/** + * Remembers which plan items still lack their statutory expiry timer. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ +class UnarmedTimerMarkers { + + /** + * The app-config key of the marker map (a lazy value: it is read by one job, not on a request). + */ + public const KEY = 'case_unarmed_statutory_timers'; + + /** + * Constructor. + * + * @param IAppConfig $appConfig OpenRegister's app config. + */ + public function __construct( + private readonly IAppConfig $appConfig, + ) { + + }//end __construct() + + /** + * Mark an item: its statutory expiry timer could not be armed. + * + * The first marker of an item keeps its moment, so how long an item has + * been without its timer stays readable. + * + * @param string $itemUuid The plan item. + * @param DateTimeInterface|null $now The clock; null is the real clock. + * + * @return bool True when the marker is stored. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + public function add(string $itemUuid, ?DateTimeInterface $now = null): bool { + $markers = $this->all(); + if (isset($markers[$itemUuid]) === true) { + return true; + } + + $moment = DateTimeImmutable::createFromInterface($now ?? new DateTimeImmutable()); + $markers[$itemUuid] = $moment->format(DATE_ATOM); + + return $this->store(markers: $markers); + }//end add() + + /** + * Clear an item's marker. + * + * @param string $itemUuid The plan item. + * + * @return void + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + public function remove(string $itemUuid): void { + $markers = $this->all(); + if (isset($markers[$itemUuid]) === false) { + return; + } + + unset($markers[$itemUuid]); + $this->store(markers: $markers); + }//end remove() + + /** + * Every marked item, with the moment its arm first failed. + * + * @return array Item uuid to an ISO 8601 moment. + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + public function all(): array { + try { + $decoded = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY, '', true), true); + } catch (Throwable) { + return []; + } + + if (is_array($decoded) === false) { + return []; + } + + $markers = []; + foreach ($decoded as $uuid => $moment) { + if (is_string($uuid) === true && $uuid !== '' && is_string($moment) === true) { + $markers[$uuid] = $moment; + } + } + + return $markers; + }//end all() + + /** + * Store the map, or drop the key when it is empty. + * + * @param array $markers The map. + * + * @return bool True when stored. + */ + private function store(array $markers): bool { + try { + if ($markers === []) { + $this->appConfig->deleteKey(Application::APP_ID, self::KEY); + + return true; + } + + return $this->appConfig->setValueString(Application::APP_ID, self::KEY, (string)json_encode($markers), true); + } catch (Throwable) { + return false; + } + }//end store() +}//end class diff --git a/lib/Service/Configuration/DescriptorConfigIds.php b/lib/Service/Configuration/DescriptorConfigIds.php new file mode 100644 index 0000000000..5e792a1575 --- /dev/null +++ b/lib/Service/Configuration/DescriptorConfigIds.php @@ -0,0 +1,98 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Configuration + * + * @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/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Configuration; + +/** + * Maps an OpenRegister descriptor file to the configuration id its import runs under. + */ +final class DescriptorConfigIds { + + /** + * The id the descriptors without a dedicated one share. + */ + public const SHARED = 'openregister'; + + public const DECISION_TABLES = 'openregister.decision-tables'; + + public const DISMISSED_PAIR = 'openregister.dismissed-pair'; + + public const EDEPOT_TRANSFERS = 'openregister.edepot-transfers'; + + public const MERGE_OPERATION = 'openregister.merge-operation'; + + public const TRUST_CONFIGURATION = 'openregister.trust-configuration'; + + public const VOCABULARY = 'openregister.vocabulary'; + + public const DSAR_CASES = 'openregister.dsar-cases'; + + public const DSAR_POLICY_PACKS = 'openregister.dsar-policy-packs'; + + /** + * The descriptors that import under an id of their own, by file name. + * + * @var array + */ + public const BY_FILE = [ + 'decision_table_register.json' => self::DECISION_TABLES, + 'dismissed_pair_register.json' => self::DISMISSED_PAIR, + 'edepot_transfer_register.json' => self::EDEPOT_TRANSFERS, + 'merge_operation_register.json' => self::MERGE_OPERATION, + 'trust_configuration_register.json' => self::TRUST_CONFIGURATION, + 'vocabulary_register.json' => self::VOCABULARY, + 'data_subject_request_register.json' => self::DSAR_CASES, + 'dsar_policy_pack_register.json' => self::DSAR_POLICY_PACKS, + ]; + + /** + * The configuration id a descriptor of an app is imported under. + * + * Only OpenRegister's own descriptors have a dedicated id; any other app's + * file, whatever it is called, imports under the app's id. + * + * @param string $appId The app that ships the descriptor. + * @param string $file The descriptor's file name. + * + * @return string The configuration id. + * + * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md + */ + public static function forFile(string $appId, string $file): string { + if ($appId !== self::SHARED) { + return $appId; + } + + return (self::BY_FILE[$file] ?? self::SHARED); + }//end forFile() +}//end class diff --git a/lib/Service/Configuration/ImportHandler.php b/lib/Service/Configuration/ImportHandler.php index 54966792ef..fc652d1263 100644 --- a/lib/Service/Configuration/ImportHandler.php +++ b/lib/Service/Configuration/ImportHandler.php @@ -300,6 +300,13 @@ class ImportHandler { */ private ?SelectionListSeeder $selectionListSeeder = null; + /** + * The shipped version of each seed object, built on first use from the app config. + * + * @var SeedVersionLedger|null + */ + private ?SeedVersionLedger $seedVersions = null; + /** * Collector for declared RBAC group ids. Dependency-free value object, * created lazily via {@see self::rbacGroupCollector()}. @@ -3609,7 +3616,21 @@ private function importListedObject(array $objectData, ?string $appId, ?IUser $a $importedVersion = $objectData['@self']['version'] ?? $objectData['version'] ?? '1.0.0'; $existingVersion = $existingObjectData['@self']['version'] ?? $existingObjectData['version'] ?? '1.0.0'; - if (version_compare($importedVersion, $existingVersion, '>') > 0) { + + // Compared with the version this seed was last SHIPPED at when the + // import remembers one, not with the object's own version: every + // later save of the object bumps its patch, and a stored `1.4.8` + // (shipped 1.4.7, edited once) would otherwise make a shipped + // `1.4.8` look current and keep it from ever arriving. + $seedKey = null; + $shipped = null; + if ($appId !== null && $appId !== '') { + $seedKey = (int)$registerId . '/' . (int)$schemaId . '/' . $slug; + $shipped = $this->seedVersions()->shipped(appId: $appId, seed: $seedKey); + } + + $baseline = ($shipped ?? $existingVersion); + if (version_compare($importedVersion, $baseline, '>') > 0) { $uuid = $existingObjectData['@self']['id'] ?? $existingObjectData['id'] ?? null; // CRITICAL: Pass Register and Schema OBJECTS, not IDs. // This avoids organisation filter issues in find(). @@ -3628,15 +3649,22 @@ private function importListedObject(array $objectData, ?string $appId, ?IUser $a currentUser: $actingUser ); $result['objects'][] = $object; + $this->rememberShippedVersion(appId: $appId, seedKey: $seedKey, version: (string)$importedVersion); } - if (version_compare($importedVersion, $existingVersion, '>') <= 0) { + if (version_compare($importedVersion, $baseline, '>') <= 0) { // COUNTED, not just logged. Skipping an unchanged // object is the DESIGNED behaviour of a re-import, // but a debug line is invisible to the caller, and // the caller is the one that has to tell an operator // whether the import worked. $result['unchanged']['objects']++; + if ($shipped === null) { + // A seed stored before the import remembered its shipped version: what the file ships now is + // what the instance holds (the object cannot be told apart from an edited one otherwise). + $this->rememberShippedVersion(appId: $appId, seedKey: $seedKey, version: (string)$importedVersion); + } + $this->logger->debug( message: '[ImportHandler] Skipped object update: imported version not higher', context: [ @@ -3647,6 +3675,7 @@ private function importListedObject(array $objectData, ?string $appId, ?IUser $a 'schema' => $schemaId, 'importedVersion' => $importedVersion, 'existingVersion' => $existingVersion, + 'shippedVersion' => $shipped, ] ); return; @@ -3671,9 +3700,46 @@ private function importListedObject(array $objectData, ?string $appId, ?IUser $a currentUser: $actingUser ); $result['objects'][] = $object; + if ($appId !== null && $appId !== '') { + $this->rememberShippedVersion( + appId: $appId, + seedKey: (int)$registerId . '/' . (int)$schemaId . '/' . $slug, + version: (string)($objectData['@self']['version'] ?? $objectData['version'] ?? '1.0.0') + ); + } }//end if }//end importListedObject() + /** + * The ledger of the versions the seed objects were last shipped at. + * + * @return SeedVersionLedger + */ + private function seedVersions(): SeedVersionLedger { + if ($this->seedVersions === null) { + $this->seedVersions = new SeedVersionLedger(appConfig: $this->appConfig); + } + + return $this->seedVersions; + }//end seedVersions() + + /** + * Remember the version a seed object was shipped at, when the import knows its app. + * + * @param string|null $appId The importing app. + * @param string|null $seedKey The seed's identity; null when there is no app to remember it under. + * @param string $version The shipped version. + * + * @return void + */ + private function rememberShippedVersion(?string $appId, ?string $seedKey, string $version): void { + if ($appId === null || $appId === '' || $seedKey === null) { + return; + } + + $this->seedVersions()->record(appId: $appId, seed: $seedKey, version: $version); + }//end rememberShippedVersion() + /** * Record a listed object that failed twice: counted, named and logged. * diff --git a/lib/Service/Configuration/SeedVersionLedger.php b/lib/Service/Configuration/SeedVersionLedger.php new file mode 100644 index 0000000000..17d66d13bc --- /dev/null +++ b/lib/Service/Configuration/SeedVersionLedger.php @@ -0,0 +1,150 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Configuration + * + * @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/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Configuration; + +use OCP\IAppConfig; + +/** + * Remembers the shipped version of each seed object, per app id. + * + * @spec openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md + */ +class SeedVersionLedger { + + /** + * The app-config key prefix of one app id's map. + */ + private const KEY_PREFIX = 'imported_seed_versions_'; + + /** + * Longest app config key Nextcloud accepts. + */ + private const MAX_KEY_LENGTH = 64; + + /** + * The maps read so far in this request, by app id. + * + * @var array> + */ + private array $maps = []; + + /** + * Constructor. + * + * @param IAppConfig $appConfig OpenRegister's app config. + */ + public function __construct( + private readonly IAppConfig $appConfig, + ) { + + }//end __construct() + + /** + * The version a seed was last shipped at, or null when the import never recorded one. + * + * @param string $appId The importing app id. + * @param string $seed The seed's identity: register id, schema id and slug. + * + * @return string|null The version. + * + * @spec openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md + */ + public function shipped(string $appId, string $seed): ?string { + return ($this->map(appId: $appId)[$seed] ?? null); + }//end shipped() + + /** + * Record the version a seed was shipped at, when it differs from the recorded one. + * + * @param string $appId The importing app id. + * @param string $seed The seed's identity: register id, schema id and slug. + * @param string $version The shipped version. + * + * @return void + * + * @spec openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md + */ + public function record(string $appId, string $seed, string $version): void { + $map = $this->map(appId: $appId); + if (($map[$seed] ?? null) === $version) { + return; + } + + $map[$seed] = $version; + $this->maps[$appId] = $map; + $this->appConfig->setValueString('openregister', $this->key(appId: $appId), (string)json_encode($map), lazy: true); + }//end record() + + /** + * One app id's map, read once per request. + * + * @param string $appId The app id. + * + * @return array Seed identity to shipped version. + */ + private function map(string $appId): array { + if (isset($this->maps[$appId]) === false) { + $decoded = json_decode($this->appConfig->getValueString('openregister', $this->key(appId: $appId), '', lazy: true), true); + if (is_array($decoded) === false) { + $decoded = []; + } + + $map = []; + foreach ($decoded as $seed => $version) { + if (is_string($version) === true) { + $map[(string)$seed] = $version; + } + } + + $this->maps[$appId] = $map; + } + + return $this->maps[$appId]; + }//end map() + + /** + * The app-config key of an app id's map, hashed when the plain key would pass Nextcloud's limit. + * + * @param string $appId The app id. + * + * @return string + */ + private function key(string $appId): string { + $key = self::KEY_PREFIX . $appId; + if (strlen($key) <= self::MAX_KEY_LENGTH) { + return $key; + } + + return self::KEY_PREFIX . sha1($appId); + }//end key() +}//end class diff --git a/lib/Service/CountedRegisterImport.php b/lib/Service/CountedRegisterImport.php index 5cf4dd3a60..95c8b5f280 100644 --- a/lib/Service/CountedRegisterImport.php +++ b/lib/Service/CountedRegisterImport.php @@ -62,7 +62,8 @@ public function __construct( * @param string $version The version of the imported document. * @param int $fragments How many register.d fragments it carries. * @param string $via Who asked, for the audit trail. - * @param callable $import Runs the import; returns success, path, via, message. + * @param callable $import Runs the import; returns success, path, via, message, and importJobId and + * importAppId when it recorded an import job. * * @return array The reimport() result. * @@ -96,6 +97,13 @@ public function run( 'replaced' => $replaced, ]; + // The job the import ran under, so a caller that must undo exactly this import (the example-set loader) is + // told which one it was and does not have to guess it from a before/after diff of every job on the instance. + if (is_string($run['importJobId'] ?? null) === true) { + $result['importJobId'] = $run['importJobId']; + $result['importAppId'] = (string) ($run['importAppId'] ?? ''); + } + if ($run['success'] === false) { $result['outcome'] = 'failed'; $result['reason'] = sprintf('The import through %s failed: %s', $run['via'], $run['message']); 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/lib/Service/Decision/DecisionVisibility.php b/lib/Service/Decision/DecisionVisibility.php index 2f3043dc52..9eae3fa434 100644 --- a/lib/Service/Decision/DecisionVisibility.php +++ b/lib/Service/Decision/DecisionVisibility.php @@ -55,8 +55,12 @@ class DecisionVisibility { * `bases` is the ground; `skipAnonymization` is the release-or-redact * verdict itself, so leaving it would tell the second reviewer what the * first one decided. `decisionRecord` is the embedded decision. + * `anonymized` and `anonymizedValue` are what an anonymise pass wrote from + * that verdict: a released occurrence stays `anonymized: false` while a + * redacted one reads `true` with its placeholder, so left in they would + * give the verdict away in a blind second review that follows a pass. */ - public const MASKED_FIELDS = ['bases', 'skipAnonymization', 'decisionRecord']; + public const MASKED_FIELDS = ['bases', 'skipAnonymization', 'decisionRecord', 'anonymized', 'anonymizedValue']; /** * Constructor. diff --git a/lib/Service/Decision/RelationDecisionService.php b/lib/Service/Decision/RelationDecisionService.php index 3e132599a0..67c59137ca 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; @@ -40,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 { @@ -91,7 +95,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,14 +106,9 @@ 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) { - $resolved = $this->policies->forRelation(relation: $relation); - $refusal = $this->ownership->refusal(decision: $current, actorId: $actorId, policy: $resolved['policy'], subject: $resolved['subject']); - if ($refusal !== null) { - throw new DecisionOwnedException(refusal: $refusal); - } - } + $linkedUuid = $relation->getDecisionUuid(); + $current = $this->decisions->find(uuid: $linkedUuid); + $this->assertMayChange(relation: $relation, current: $current, linkedUuid: $linkedUuid, actorId: $actorId); $before = [$relation->getBases(), $relation->getSkipAnonymization()]; @@ -140,8 +139,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; @@ -150,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/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/lib/Service/Deferral/ListenerDeferralService.php b/lib/Service/Deferral/ListenerDeferralService.php index 0a7db1e081..699b774983 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,16 @@ 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`, 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. * @@ -299,13 +347,39 @@ 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. 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; conditioned updated rules and ' + . 'calculatedChange rules are skipped for this object', + 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 +389,7 @@ private function chunksUnderLimit(string $jobClass, array $entries): array { 'jobClass' => $jobClass, 'uuid' => (string)($entry['uuid'] ?? ''), 'length' => $length, + 'limit' => $this->getArgumentLimit(), ] ); continue; diff --git a/lib/Service/Dmn/DecisionTableStore.php b/lib/Service/Dmn/DecisionTableStore.php index 8d025870c9..b6cbcdcaca 100644 --- a/lib/Service/Dmn/DecisionTableStore.php +++ b/lib/Service/Dmn/DecisionTableStore.php @@ -82,7 +82,7 @@ public function __construct( * * @return array The table: key, name, hitPolicy, inputs, outputs, rules. * - * @throws UnexpectedValueException When no table carries the reference, or the stored table cannot be executed. + * @throws UnexpectedValueException When no table carries the reference, more than one does, or the stored table cannot be executed. * * @spec openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md */ @@ -147,6 +147,8 @@ public function evaluate(string $reference, array $inputs): array { * @param string $reference The table's key or uuid. * * @return array|null The table, or null when none carries the reference. + * + * @throws UnexpectedValueException When the register cannot be read, or more than one table carries the key. */ private function lookup(string $reference): ?array { try { @@ -165,6 +167,7 @@ private function lookup(string $reference): ?array { ); } + $byKey = []; $byUuid = null; foreach ((array)$rows as $entity) { $data = $entity->getObject(); @@ -173,7 +176,8 @@ private function lookup(string $reference): ?array { } if (trim((string)($data['key'] ?? '')) === $reference) { - return $data; + $byKey[(string)$entity->getUuid()] = $data; + continue; } if ($byUuid === null && (string)$entity->getUuid() === $reference) { @@ -181,6 +185,19 @@ private function lookup(string $reference): ?array { } } + // Two rows with one key (a save that went around the guard, an import) are two tables, and the register + // returns them in no fixed order: answering with the first would decide by a table nobody chose, and + // which one could change between requests. + if (count($byKey) > 1) { + throw new UnexpectedValueException( + sprintf('More than one decision table has the key "%s" (%s); the key must name exactly one.', $reference, implode(', ', array_keys($byKey))) + ); + } + + if ($byKey !== []) { + return array_values($byKey)[0]; + } + return $byUuid; }//end lookup() }//end class diff --git a/lib/Service/ExampleSet/ExampleSetAppConfig.php b/lib/Service/ExampleSet/ExampleSetAppConfig.php index f8d7531ac1..60d64ecec5 100644 --- a/lib/Service/ExampleSet/ExampleSetAppConfig.php +++ b/lib/Service/ExampleSet/ExampleSetAppConfig.php @@ -101,8 +101,21 @@ public function apply(string $app, string $key, string $value, bool $generate): try { if ($generate === true) { - $this->appConfig->setValueString($app, $key, $this->random->generate(self::GENERATED_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC), false, true); - return ['error' => '', 'entry' => ['app' => $app, 'key' => $key, 'had' => $had, 'old' => '', 'new' => '', 'generated' => true]]; + $generated = $this->random->generate(self::GENERATED_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC); + $this->appConfig->setValueString($app, $key, $generated, false, true); + // A one-way fingerprint, never the value: remove can tell the generated value from one an administrator put there. + return [ + 'error' => '', + 'entry' => [ + 'app' => $app, + 'key' => $key, + 'had' => $had, + 'old' => '', + 'new' => '', + 'generated' => true, + 'fingerprint' => hash('sha256', $generated), + ], + ]; } $this->appConfig->setValueString($app, $key, $value); @@ -133,6 +146,13 @@ public function lost(array $entry): bool { /** * Put back what an entry replaced, while the key still holds what the set wrote. * + * A generated value is recognised by its fingerprint (the record never + * holds the value): one that no longer has it was replaced by somebody, + * and stays. A key that held an empty value before the set generated one + * gets that value back; a key that was not there is deleted. An entry + * from before fingerprints was recorded has none to compare and is + * restored as it always was. + * * @param array $entry A record entry from apply(). * * @return string `restored`, `left` (somebody changed it since) or `failed`. @@ -142,13 +162,13 @@ public function lost(array $entry): bool { public function revert(array $entry): string { $app = (string) $entry['app']; $key = (string) $entry['key']; - $changed = ($this->has(app: $app, key: $key) === true && $this->read(app: $app, key: $key) !== (string) $entry['new']); - if (($entry['generated'] ?? false) === false && $changed === true) { + $generated = (($entry['generated'] ?? false) === true); + if ($this->changedSince(entry: $entry) === true) { return 'left'; } try { - if ($entry['had'] === true && ($entry['generated'] ?? false) === false) { + if ($entry['had'] === true && ($generated === false || isset($entry['fingerprint']) === true)) { $this->appConfig->setValueString($app, $key, (string) $entry['old']); return 'restored'; } @@ -161,6 +181,31 @@ public function revert(array $entry): string { return 'restored'; }//end revert() + /** + * Whether the key no longer holds what the set wrote: a written value that differs, or a generated one that lost its fingerprint. + * + * @param array $entry A record entry from apply(). + * + * @return bool + */ + private function changedSince(array $entry): bool { + $app = (string) $entry['app']; + $key = (string) $entry['key']; + if ($this->has(app: $app, key: $key) === false) { + return false; + } + + if (($entry['generated'] ?? false) === true) { + if (isset($entry['fingerprint']) === false) { + return false; + } + + return hash_equals((string) $entry['fingerprint'], hash('sha256', $this->read(app: $app, key: $key))) === false; + } + + return $this->read(app: $app, key: $key) !== (string) $entry['new']; + }//end changedSince() + /** * Whether a key exists. * diff --git a/lib/Service/ExampleSet/ExampleSetChanges.php b/lib/Service/ExampleSet/ExampleSetChanges.php index aace3cf4d2..d9cfe27b69 100644 --- a/lib/Service/ExampleSet/ExampleSetChanges.php +++ b/lib/Service/ExampleSet/ExampleSetChanges.php @@ -80,7 +80,18 @@ public function resolve(array $change, array $context): array { return array_merge($none, ['refusal' => 'cannot resolve '.implode(', ', array_unique($gaps))]); } - $row = $this->target(change: $change, where: (array) $fill['where'], objects: (array) ($context['objects'] ?? [])); + $found = $this->target(change: $change, where: (array) $fill['where'], objects: (array) ($context['objects'] ?? [])); + $row = $found[0] ?? null; + if (count($found) > 1) { + return array_merge( + $none, + [ + 'refusal' => count($found).' '.$change['register'].' '.$change['schema'].' rows match ' + .json_encode($fill['where'], JSON_UNESCAPED_UNICODE).'; a change needs exactly one to change', + ] + ); + } + if ($row === null && is_string($change['object'] ?? null) === true) { return array_merge($none, ['refusal' => 'object "'.$change['object'].'" is not written earlier in this fragment']); } @@ -179,29 +190,54 @@ public function apply(array $change, array $resolved): array { * * Made like a change, so only a field that differs is written, its old * value is recorded, the install proves it and remove can put it back. + * A field is refreshed only while it still holds what the set wrote + * (`$written`): a different value is somebody's edit and is left as it + * is, and named. * - * @param array $object The declared object (`register`, `schema`, `jsonFields`). - * @param array $row The object as stored. - * @param array $values The resolved values, by top-level field. + * @param array $object The declared object (`register`, `schema`, `jsonFields`). + * @param array $row The object as stored. + * @param array $values The resolved values, by top-level field. + * @param array $written What the set last wrote into those fields, by field. * - * @return array{error: string, entry: array|null} The record entry (null when every field - * already holds its value), or why the write failed. + * @return array{error: string, entry: array|null, left: array} The record entry (null + * when no field had to be written), or why the write failed, and the fields left because somebody changed them. * * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-kept-object-follows-the-objects-it-references-req-exs-023 */ - public function refresh(array $object, array $row, array $values): array { + public function refresh(array $object, array $row, array $values, array $written=[]): array { $change = ['register' => (string) $object['register'], 'schema' => (string) $object['schema'], 'jsonFields' => $this->jsonFields(change: $object)]; $row = $this->paths->withJsonDecoded(row: $row, fields: $change['jsonFields']); - return $this->apply( + $same = static fn (mixed $first, mixed $second): bool => json_encode($first) === json_encode($second); + $todo = []; + $left = []; + foreach ($values as $field => $value) { + $held = $this->paths->atPath(row: $row, path: (string) $field); + if ($held['found'] === true && $same($held['value'], $value) === true) { + continue; + } + + $wrote = array_key_exists($field, $written); + if ($held['found'] === true && $wrote === true && $same($held['value'], $written[$field]) === true) { + $todo[$field] = $value; + continue; + } + + $left[] = (string) $field; + } + + // Nothing to write (every field already holds its value, or was edited) makes apply() answer no entry. + $made = $this->apply( change: $change, resolved: [ 'row' => $row, - 'merged' => $this->paths->withJsonText(row: array_merge($row, $values), fields: $change['jsonFields']), - 'set' => $values, + 'merged' => $this->paths->withJsonText(row: array_merge($row, $todo), fields: $change['jsonFields']), + 'set' => $todo, 'append' => [], ] ); + + return ($made + ['left' => $left]); }//end refresh() /** @@ -239,24 +275,37 @@ public function lost(array $entry): array { }//end lost() /** - * Put back what a change replaced and take out what it appended. + * Put back what a change replaced and take out what it appended, while the object still holds what the change wrote. + * + * A path whose value is no longer the one the change wrote was edited + * since: it is left as it is now and named, as app config and user + * preferences are. A value the change appended is taken out of its list + * wherever the list is now, because that undoes exactly the change. * * @param array $entry A record entry from apply(). * - * @return bool True when the object no longer carries the change, or is gone. + * @return array{ok: bool, left: array} Whether the object no longer carries the change (or is gone), + * and the paths left because somebody changed them. * * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-remove-deletes-exactly-what-the-install-made-req-exs-007 * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-field-a-schema-keeps-as-json-text-is-sent-as-text-req-exs-012 */ - public function revert(array $entry): bool { + public function revert(array $entry): array { $row = $this->store->get(register: (string) $entry['register'], schema: (string) $entry['schema'], id: (string) $entry['id']); if ($row === null) { - return true; + return ['ok' => true, 'left' => []]; } $fields = $this->jsonFields(change: $entry); $row = $this->paths->withJsonDecoded(row: $row, fields: $fields); + $left = []; foreach (array_reverse((array) $entry['set']) as $set) { + $held = $this->paths->atPath(row: $row, path: $set['path']); + if ($held['found'] === false || $held['value'] !== $set['new']) { + $left[] = (string) $set['path']; + continue; + } + $row = $this->paths->withoutPath(row: $row, path: $set['path']); if ($set['had'] === true) { $row = $this->paths->withPath(row: $row, path: $set['path'], value: $set['old']); @@ -277,8 +326,9 @@ public function revert(array $entry): bool { } $row = $this->paths->withJsonText(row: $row, fields: $fields); + $why = $this->store->update(register: (string) $entry['register'], schema: (string) $entry['schema'], id: (string) $entry['id'], data: $row); - return $this->store->update(register: (string) $entry['register'], schema: (string) $entry['schema'], id: (string) $entry['id'], data: $row) === ''; + return ['ok' => ($why === ''), 'left' => $left]; }//end revert() /** @@ -309,39 +359,29 @@ private function jsonFields(array $change): array { }//end jsonFields() /** - * The one object a change describes: an object written earlier in the same - * fragment by its key, or the object its `where` finds; null otherwise. + * The objects a change describes: the object written earlier in the same + * fragment by its key, or every object its `where` finds. * * @param array $change The declared change. * @param array $where The filled `where`. * @param array> $objects The fragment's objects so far, by key. * - * @return array|null + * @return array> None, one, or every match; a change is made only on exactly one. */ - private function target(array $change, array $where, array $objects): ?array { + private function target(array $change, array $where, array $objects): array { if (is_string($change['object'] ?? null) === true) { - return ($objects[$change['object']] ?? null); - } + $earlier = ($objects[$change['object']] ?? null); + if ($earlier === null) { + return []; + } - $register = (string) $change['register']; - $schema = (string) $change['schema']; - if ($where === []) { - return null; + return [$earlier]; } - foreach ([$where, []] as $filters) { - foreach ($this->store->find(register: $register, schema: $schema, filters: $filters) as $row) { - $match = true; - foreach ($where as $field => $value) { - $match = ($match === true && ($row[$field] ?? null) === $value); - } - - if ($match === true) { - return $row; - } - } + if ($where === []) { + return []; } - return null; + return $this->store->matching(register: (string) $change['register'], schema: (string) $change['schema'], where: $where); }//end target() }//end class diff --git a/lib/Service/ExampleSet/ExampleSetInstaller.php b/lib/Service/ExampleSet/ExampleSetInstaller.php index 0967980de9..52693d43a6 100644 --- a/lib/Service/ExampleSet/ExampleSetInstaller.php +++ b/lib/Service/ExampleSet/ExampleSetInstaller.php @@ -134,7 +134,7 @@ public function check(array $plan, array $adopt=[], ?DateTimeImmutable $today=nu $standIn = $this->standIn(app: $fragment['app'], object: $object, data: $resolved['data']); $planned[] = ['register' => $object['register'], 'schema' => $object['schema'], 'row' => $standIn]; - $kept = $this->kept(record: $record, app: $fragment['app'], key: $object['key']); + $kept = $this->kept(record: $record, app: $fragment['app'], key: $object['key'])['row']; $objects[$object['key']] = ($kept ?? $standIn); }//end foreach @@ -180,6 +180,7 @@ public function install(array $plan, array $adopt=[], string $password='', ?Date 'passwords' => [], 'types' => [], 'dropped' => [], + 'left' => [], 'missing' => [], 'lost' => [], 'ok' => false, @@ -296,7 +297,16 @@ private function installFragment(array $plan, array $fragment, array $base, arra $report['types'][$type] ??= ['declared' => 0, 'created' => 0, 'kept' => 0, 'arrived' => 0]; $report['types'][$type]['declared']++; - $kept = $this->kept(record: $record, app: $fragment['app'], key: $object['key']); + $held = $this->kept(record: $record, app: $fragment['app'], key: $object['key']); + if ($held['error'] !== '') { + // A read that failed proves nothing about the object: writing it again would leave two copies and + // forget the original, so nothing is written and the recorded id stays. + $report['dropped'][] = $fragment['file'].': object "'.$object['key'].'": the object the record holds could not be read ('.$held['error'].')'; + $objects[$object['key']] = ['id' => (string) $record['objects'][$name]['id']]; + continue; + } + + $kept = $held['row']; if ($kept !== null) { $report['types'][$type]['kept']++; $objects[$object['key']] = $this->refreshReferences( @@ -329,6 +339,11 @@ private function installFragment(array $plan, array $fragment, array $base, arra $report['types'][$type]['created']++; $record['objects'][$name] = ['register' => $object['register'], 'schema' => $object['schema'], 'id' => $made['id']]; + $refs = array_intersect_key($resolved['data'], array_flip($this->referenceFields(object: $object))); + if ($refs !== []) { + $record['objects'][$name]['refs'] = $refs; + } + $this->record->write(set: (string) $plan['set'], record: $record); $written[$name] = $resolved['data']; $report = $this->attachFiles(plan: $plan, fragment: $fragment, object: $object, objectId: $made['id'], report: $report, record: $record); @@ -383,7 +398,16 @@ private function refreshReferences(array $plan, array $fragment, array $object, return $row; } - $made = $this->changes->refresh(object: $object, row: $row, values: array_intersect_key($resolved['data'], array_flip($fields))); + $made = $this->changes->refresh( + object: $object, + row: $row, + values: array_intersect_key($resolved['data'], array_flip($fields)), + written: $this->writtenReferences(record: $record, name: $name, key: $fragment['app'].'/'.$object['key']) + ); + foreach ($made['left'] as $field) { + $report['left'][] = $fragment['file'].': object "'.$object['key'].'": field "'.$field.'" was changed after the install and is left as it is'; + } + if ($made['error'] !== '') { $report['dropped'][] = $fragment['file'].': object "'.$object['key'].'": its references cannot be refreshed: '.$made['error']; $report['changes'][$name] = 'failed'; @@ -410,6 +434,29 @@ private function refreshReferences(array $plan, array $fragment, array $object, return ($this->store->get(register: $object['register'], schema: $object['schema'], id: (string) $entry['id']) ?? $row); }//end refreshReferences() + /** + * What the install last wrote into the reference fields of a kept object. + * + * The value a refresh wrote when there was one (the newest `new` of the + * `#references` change), else the value the object was created with. + * + * @param array $record The record. + * @param string $name The record key of the refresh change, `/#references`. + * @param string $key The record key of the object, `/`. + * + * @return array The value by field; a field of a record that predates this is missing. + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-kept-object-follows-the-objects-it-references-req-exs-023 + */ + private function writtenReferences(array $record, string $name, string $key): array { + $written = (array) ($record['objects'][$key]['refs'] ?? []); + foreach ((array) ($record['changes'][$name]['set'] ?? []) as $set) { + $written[(string) $set['path']] = $set['new']; + } + + return $written; + }//end writtenReferences() + /** * The top-level fields of an object whose declared value references another object. * @@ -544,9 +591,10 @@ private function resolve(array $object, array $context, array $planned): array { /** * The id of the one row a lookup by business key describes. * - * The match is checked here as well: a register that does not filter on a + * The match is checked as well: a register that does not filter on a * property hands over every row, and the first of those is not the one - * that was asked for. + * that was asked for. Two rows that match (two organisations may share a + * business key) refuse the lookup, naming the count. * * @param string $register The register of the object being written. * @param array $lookup `schema` and `where`; `register` when it is another one. @@ -568,14 +616,13 @@ private function lookedUp(string $register, array $lookup, array $context, array return ['id' => '', 'reason' => 'nothing: a lookup needs a schema and a where']; } - // Asked with the filter first, then without: OpenRegister does not - // filter on every kind of property, and the match is checked here anyway. - foreach ([$where, []] as $filters) { - foreach ($this->store->find(register: $from, schema: $schema, filters: $filters) as $row) { - if ($this->matches(row: $row, where: $where) === true) { - return ['id' => $this->store->idOf(row: $row), 'reason' => '']; - } - } + $rows = $this->store->matching(register: $from, schema: $schema, where: $where); + if (count($rows) > 1) { + return ['id' => '', 'reason' => count($rows).' '.$from.' '.$schema.' rows with '.$this->asWords(where: $where).'; a lookup needs exactly one']; + } + + if ($rows !== []) { + return ['id' => $this->store->idOf(row: $rows[0]), 'reason' => '']; } $id = $this->plannedMatch(planned: $planned, register: $from, schema: $schema, where: $where); @@ -583,13 +630,24 @@ private function lookedUp(string $register, array $lookup, array $context, array return ['id' => $id, 'reason' => '']; } + return ['id' => '', 'reason' => 'no '.$from.' '.$schema.' with '.$this->asWords(where: $where)]; + }//end lookedUp() + + /** + * The asked values as words for a refusal. + * + * @param array $where The asked values, by field. + * + * @return string + */ + private function asWords(array $where): string { $words = []; foreach ($where as $field => $value) { $words[] = $field.' '.json_encode($value, JSON_UNESCAPED_UNICODE); } - return ['id' => '', 'reason' => 'no '.$from.' '.$schema.' with '.implode(' and ', $words)]; - }//end lookedUp() + return implode(' and ', $words); + }//end asWords() /** * The stand-in id of an object declared earlier in the set that a lookup describes, or ''. @@ -603,7 +661,7 @@ private function lookedUp(string $register, array $lookup, array $context, array */ private function plannedMatch(array $planned, string $register, string $schema, array $where): string { foreach ($planned as $object) { - if ($object['register'] === $register && $object['schema'] === $schema && $this->matches(row: $object['row'], where: $where) === true) { + if ($object['register'] === $register && $object['schema'] === $schema && $this->store->rowMatches(row: $object['row'], where: $where) === true) { return (string) $object['row']['id']; } } @@ -611,45 +669,25 @@ private function plannedMatch(array $planned, string $register, string $schema, return ''; }//end plannedMatch() - /** - * Whether a row holds every asked value. - * - * @param array $row The row. - * @param array $where The asked values, by field. - * - * @return bool - */ - private function matches(array $row, array $where): bool { - foreach ($where as $field => $value) { - $held = ($row[$field] ?? null); - if ($held === $value) { - continue; - } - - if (is_scalar($held) === false || is_scalar($value) === false || (string) $held !== (string) $value) { - return false; - } - } - - return true; - }//end matches() - /** * The row the record holds for an object, when it is still there. * + * A read that failed is not "it is gone": the error comes back and the caller must not write the object again. + * * @param array $record The record. * @param string $app The app of the fragment. * @param string $key The object key. * - * @return array|null + * @return array{row: array|null, error: string} The row; null with '' when the record holds none or the + * instance lost it; null with why when the read failed. */ - private function kept(array $record, string $app, string $key): ?array { + private function kept(array $record, string $app, string $key): array { $held = ($record['objects'][$app.'/'.$key] ?? null); if ($held === null) { - return null; + return ['row' => null, 'error' => '']; } - return $this->store->get(register: $held['register'], schema: $held['schema'], id: $held['id']); + return $this->store->read(register: $held['register'], schema: $held['schema'], id: $held['id']); }//end kept() /** diff --git a/lib/Service/ExampleSet/ExampleSetInstance.php b/lib/Service/ExampleSet/ExampleSetInstance.php index 633f55e857..d3f6bad30f 100644 --- a/lib/Service/ExampleSet/ExampleSetInstance.php +++ b/lib/Service/ExampleSet/ExampleSetInstance.php @@ -32,6 +32,7 @@ use OCA\OpenRegister\Db\Organisation; use OCA\OpenRegister\Db\OrganisationMapper; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\IConfig; use Psr\Container\ContainerInterface; use Throwable; @@ -47,6 +48,15 @@ class ExampleSetInstance { */ private const LIMIT = 1000; + /** + * What delete() reports. + */ + public const DELETED = 'deleted'; + + public const LEFT = 'left'; + + public const FAILED = 'failed'; + /** * Constructor. * @@ -126,8 +136,11 @@ public function ensure(array $declared): array { try { $found = $this->mapper()->findBySlug(slug: $declared['slug']); return ['uuid' => (string) $found->getUuid(), 'created' => false, 'error' => '']; - } catch (Throwable) { + } catch (DoesNotExistException) { // Not there: created below. + } catch (Throwable $e) { + // A lookup that failed says nothing about the organisation; creating one now could duplicate the slug. + return ['uuid' => '', 'created' => false, 'error' => $e->getMessage()]; } try { @@ -144,28 +157,44 @@ public function ensure(array $declared): array { }//end ensure() /** - * Delete an organisation by uuid; true when it is gone. + * Delete an organisation by uuid unless somebody else belongs to it. + * + * Only a lookup that says the organisation does not exist reads as gone; + * any other error leaves it in the record to be tried again. An + * organisation with a member that is neither an account the set made or + * adopted nor a leftover of an account that no longer exists is a tenant + * now, and deleting it would change what its objects mean and who sees + * them, so it stays. * - * @param string $uuid The organisation's uuid. + * @param string $uuid The organisation's uuid. + * @param (callable(string): bool)|null $ignorable Whether a member does not keep the organisation; null: none does. * - * @return bool + * @return string `deleted` (also when it was already gone), `left` (it has other members) or `failed`. * * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-remove-deletes-exactly-what-the-install-made-req-exs-007 */ - public function delete(string $uuid): bool { + public function delete(string $uuid, ?callable $ignorable = null): string { try { $organisation = $this->mapper()->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return self::DELETED; } catch (Throwable) { - return true; + return self::FAILED; + } + + foreach (($organisation->getUsers() ?? []) as $member) { + if ($ignorable === null || $ignorable((string) $member) === false) { + return self::LEFT; + } } try { $this->mapper()->delete(entity: $organisation); } catch (Throwable) { - return false; + return self::FAILED; } - return true; + return self::DELETED; }//end delete() /** diff --git a/lib/Service/ExampleSet/ExampleSetPrerequisites.php b/lib/Service/ExampleSet/ExampleSetPrerequisites.php index a391d5c7ba..c691af5ebb 100644 --- a/lib/Service/ExampleSet/ExampleSetPrerequisites.php +++ b/lib/Service/ExampleSet/ExampleSetPrerequisites.php @@ -103,7 +103,8 @@ public function context(array $plan, DateTimeImmutable $today): array { }//end context() /** - * Import every required register that is not on the instance, and record the import. + * Import every required register that is not on the instance, or that this set imported and removed the data of, + * and record the import. * * @param array $plan The plan. * @param array $record The record; gains the imports. @@ -117,7 +118,9 @@ private function ensureRegisters(array $plan, array &$record): array { foreach ((array) ($plan['requires'] ?? []) as $slug => $required) { $slug = (string) $slug; $result['outcome'][$slug] = 'kept'; - if ($this->registers->exists(slug: $slug) === true) { + // A register this set imported whose import was removed again is on the instance without its data. + $dataRemoved = (array_key_exists($slug, $record['registers']) === true && $record['registers'][$slug] === []); + if ($this->registers->exists(slug: $slug) === true && $dataRemoved === false) { continue; } diff --git a/lib/Service/ExampleSet/ExampleSetRecord.php b/lib/Service/ExampleSet/ExampleSetRecord.php index 0a510289cf..29d7fdf761 100644 --- a/lib/Service/ExampleSet/ExampleSetRecord.php +++ b/lib/Service/ExampleSet/ExampleSetRecord.php @@ -91,7 +91,7 @@ public function __construct( */ public function read(string $set): array { $record = self::BLANK; - $decoded = json_decode($this->appConfig->getValueString(Application::APP_ID, self::PREFIX.$set, ''), true); + $decoded = json_decode($this->stored(set: $set), true); if (is_array($decoded) === false) { return $record; } @@ -138,7 +138,7 @@ private function memberships(array $decoded): array { * * @param array $decoded The stored objects. * - * @return array + * @return array}> */ private function objects(array $decoded): array { $objects = []; @@ -149,6 +149,10 @@ private function objects(array $decoded): array { 'schema' => (string) ($object['schema'] ?? ''), 'id' => $object['id'], ]; + // What the install wrote into the object's reference fields, so a later install can tell its own value from an edit. + if (is_array($object['refs'] ?? null) === true && $object['refs'] !== []) { + $objects[(string) $key]['refs'] = $object['refs']; + } } } @@ -166,7 +170,13 @@ private function objects(array $decoded): array { */ public function isEmpty(array $record): bool { foreach (array_keys(self::BLANK) as $part) { - if (($record[$part] ?? []) !== []) { + $held = ($record[$part] ?? []); + if ($part === 'registers') { + // A slug with no job left only remembers that its import was removed; it is not something installed. + $held = array_filter((array) $held, static fn ($jobs): bool => $jobs !== []); + } + + if ($held !== []) { return false; } } @@ -177,6 +187,9 @@ public function isEmpty(array $record): bool { /** * Keep the record, or drop the key when nothing is left in it. * + * What stays when nothing is installed is the slug of each register the + * set imported whose import jobs were all removed again (an empty job list). + * * @param string $set The set id. * @param array $record The record. * @@ -186,10 +199,51 @@ public function isEmpty(array $record): bool { */ public function write(string $set, array $record): void { if ($this->isEmpty(record: $record) === true) { + // A register this set imported and removed again is remembered, so the next install imports its data again. + if (($record['registers'] ?? []) !== []) { + $this->store(set: $set, json: (string) json_encode(['registers' => $record['registers']])); + return; + } + $this->appConfig->deleteKey(Application::APP_ID, self::PREFIX.$set); return; } - $this->appConfig->setValueString(Application::APP_ID, self::PREFIX.$set, (string) json_encode($record)); + $this->store(set: $set, json: (string) json_encode($record)); }//end write() + + /** + * The stored record as JSON text, '' when there is none. + * + * A record is kept as a lazy app-config value: Nextcloud loads every + * non-lazy value of every app on every request, and a demo instance's + * record (each object id, change, file, config entry and preference) is + * large and rewritten after every object an install creates. A record an + * earlier version stored as a non-lazy value is still found, and moves to + * the lazy store the next time it is written. + * + * @param string $set The set id. + * + * @return string + */ + private function stored(string $set): string { + $json = $this->appConfig->getValueString(Application::APP_ID, self::PREFIX.$set, '', true); + if ($json === '') { + $json = $this->appConfig->getValueString(Application::APP_ID, self::PREFIX.$set, '', false); + } + + return $json; + }//end stored() + + /** + * Store the record's JSON text as a lazy value. + * + * @param string $set The set id. + * @param string $json The record as JSON text. + * + * @return void + */ + private function store(string $set, string $json): void { + $this->appConfig->setValueString(Application::APP_ID, self::PREFIX.$set, $json, true); + }//end store() }//end class diff --git a/lib/Service/ExampleSet/ExampleSetRegisters.php b/lib/Service/ExampleSet/ExampleSetRegisters.php index 211681e7c6..9a24e10304 100644 --- a/lib/Service/ExampleSet/ExampleSetRegisters.php +++ b/lib/Service/ExampleSet/ExampleSetRegisters.php @@ -6,8 +6,7 @@ * The OpenRegister registers an example set needs, named in the cast file's * `requires` list. A required register that is not on the instance is * imported from OpenRegister's own descriptor, the same way - * `POST /api/register-descriptors/openregister/{slug}/import` does, or from - * its mock descriptor when the set requires the mock. The + * `POST /api/register-descriptors/openregister/{slug}/import` does. The * import jobs that import created are returned, so the set can remove exactly * those objects again and leave an import somebody else ran alone. * @@ -51,12 +50,6 @@ class ExampleSetRegisters { */ private const APP = 'openregister'; - /** - * The configuration ids OpenRegister's descriptor imports run under: its - * own registers, and its mock registers, which import under their own id. - */ - private const IMPORT_IDS = ['openregister', 'openregister.mock']; - /** * Constructor. * @@ -93,10 +86,16 @@ public function exists(string $slug): bool { /** * Import a register from OpenRegister's own descriptor. * + * The import job is the one the import itself reports. It is not worked out + * from the jobs the instance holds before and after: a job another process + * records in that window (a repair step during an upgrade, an + * administrator's re-import) would be taken for the set's, and remove would + * soft-delete its objects. + * * @param string $slug The register slug. * * @return array{error: string, jobs: array} Why it failed, or ''; and - * the import jobs it created. + * the import job it ran under. * * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009 */ @@ -116,7 +115,7 @@ public function import(string $slug): array { * @param string $slug The register slug. * * @return array{error: string, jobs: array} Why it failed, or ''; and - * the import jobs it created. + * the import job it ran under. * * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009 */ @@ -127,26 +126,24 @@ public function importMock(string $slug): array { }//end importMock() /** - * Run one descriptor import and return why it failed and the import jobs it created. + * Run one descriptor import and return why it failed and the import job it reports. * * @param callable(RegisterDescriptorService): array $import The import. * * @return array{error: string, jobs: array} + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009 */ private function recordedImport(callable $import): array { try { - $before = $this->jobIds(); $result = $import($this->container->get(RegisterDescriptorService::class)); - $after = $this->jobIds(); } catch (Throwable $e) { return ['error' => $e->getMessage(), 'jobs' => []]; } $jobs = []; - foreach ($after as $key => $job) { - if (isset($before[$key]) === false) { - $jobs[] = $job; - } + if (is_string($result['importJobId'] ?? null) === true && $result['importJobId'] !== '') { + $jobs[] = ['appId' => (string) ($result['importAppId'] ?? self::APP), 'jobId' => $result['importJobId']]; } $error = ''; @@ -194,21 +191,4 @@ public function removeImport(string $appId, string $jobId): bool { return $clean; }//end removeImport() - - /** - * Every recorded import job of OpenRegister's own descriptors. - * - * @return array Keyed by `appId/jobId`. - */ - private function jobIds(): array { - $recorder = $this->container->get(AppImportJobRecorder::class); - $jobs = []; - foreach (self::IMPORT_IDS as $appId) { - foreach ($recorder->jobs(appId: $appId) as $job) { - $jobs[$appId.'/'.$job['jobId']] = ['appId' => $appId, 'jobId' => (string) $job['jobId']]; - } - } - - return $jobs; - }//end jobIds() }//end class diff --git a/lib/Service/ExampleSet/ExampleSetRemover.php b/lib/Service/ExampleSet/ExampleSetRemover.php index 266a351fc1..98842e695d 100644 --- a/lib/Service/ExampleSet/ExampleSetRemover.php +++ b/lib/Service/ExampleSet/ExampleSetRemover.php @@ -131,13 +131,14 @@ public function remove(string $set): array { $record['memberships'] = array_values($record['memberships']); $this->record->write(set: $set, record: $record); + $accounts = array_map('strval', array_keys($record['users'])); $record = $this->removeGroupsAndAccounts(set: $set, record: $record, report: $report); $this->record->write(set: $set, record: $record); // Last: the set's own objects may point into a register it imported, // and may belong to an organisation it created. $record = $this->removeImports(set: $set, record: $record, report: $report); - $this->removeOrganisations(set: $set, record: $record, report: $report); + $this->removeOrganisations(set: $set, record: $record, report: $report, accounts: $accounts); return $report; }//end remove() @@ -147,17 +148,23 @@ public function remove(string $set): array { * * @param string $set The set id. * @param array $record The record. - * @param array $report The report; counts and failures are added. + * @param array $report The report; counts, paths left as somebody changed them, and failures. * * @return array The record without the reverted changes. */ private function revertChanges(string $set, array $record, array &$report): array { foreach (array_reverse($record['changes'], true) as $name => $entry) { - if ($this->changes->revert(entry: $entry) === false) { + $reverted = $this->changes->revert(entry: $entry); + if ($reverted['ok'] === false) { $report['failed'][] = 'change '.$name.' on '.$entry['register'].' '.$entry['schema'].' ('.$entry['id'].')'; continue; } + foreach ($reverted['left'] as $path) { + $report['left'][] = 'change '.$name.' on '.$entry['register'].' '.$entry['schema'].' ('.$entry['id'].'), path '.$path + .', which somebody changed after the install'; + } + unset($record['changes'][$name]); $report['removed']['changes']++; $this->record->write(set: $set, record: $record); @@ -169,6 +176,10 @@ private function revertChanges(string $set, array $record, array &$report): arra /** * Remove the register imports this set made. * + * The register itself and its schemas stay, as they may hold other people's + * data; the record keeps the slug without jobs so that an install imports + * the register's data again (REQ-EXS-009). + * * @param string $set The set id. * @param array $record The record. * @param array $report The report; counts and failures are added. @@ -188,10 +199,9 @@ private function removeImports(string $set, array $record, array &$report): arra $report['removed']['register imports']++; } + // A slug whose jobs are all gone stays with no jobs: the register and its schemas remain on the instance, but + // the data the import made does not, and the next install must import it again. $record['registers'][$slug] = $left; - if ($left === []) { - unset($record['registers'][$slug]); - } $this->record->write(set: $set, record: $record); }//end foreach @@ -302,19 +312,33 @@ private function restoreUserPreferences(string $set, array $record, array &$repo * * @param string $set The set id. * @param array $record The record. - * @param array $report The report; counts and failures are added. + * @param array $report The report; counts, organisations kept and failures are added. + * @param array $accounts The accounts the record held before remove deleted them; they, and any + * member that is no account any more, do not keep an organisation. * * @return void */ - private function removeOrganisations(string $set, array $record, array &$report): void { + private function removeOrganisations(string $set, array $record, array &$report, array $accounts): void { $left = []; foreach ($record['organisations'] as $uuid) { - if ($this->instance->delete(uuid: $uuid) === false) { + $outcome = $this->instance->delete( + uuid: $uuid, + ignorable: fn (string $member): bool => ( + in_array($member, $accounts, true) === true || $this->accounts->userExists(uid: $member) === false + ) + ); + if ($outcome === ExampleSetInstance::FAILED) { $report['failed'][] = 'OpenRegister organisation '.$uuid; $left[] = $uuid; continue; } + if ($outcome === ExampleSetInstance::LEFT) { + $report['left'][] = 'OpenRegister organisation '.$uuid.', which has members the set did not add'; + $left[] = $uuid; + continue; + } + $report['removed']['organisations']++; } diff --git a/lib/Service/ExampleSet/ExampleSetStore.php b/lib/Service/ExampleSet/ExampleSetStore.php index 58ed18ab76..f844b214bf 100644 --- a/lib/Service/ExampleSet/ExampleSetStore.php +++ b/lib/Service/ExampleSet/ExampleSetStore.php @@ -31,6 +31,7 @@ use OCA\OpenRegister\Service\Object\ValidateObject; use OCA\OpenRegister\Service\ObjectService; +use OCP\AppFramework\Db\DoesNotExistException; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Throwable; @@ -40,6 +41,8 @@ * * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The one adapter between the loader and ObjectService/ValidateObject: * every OpenRegister failure becomes a refusal or a reason here. + * @SuppressWarnings(PHPMD.TooManyPublicMethods) The loader's whole register surface (read, match, write, delete, + * ids) in one adapter, so the loader never touches ObjectService. * * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-check-validates-every-object-against-its-real-schema-without-writing-req-exs-004 */ @@ -167,9 +170,77 @@ public function find(string $register, string $schema, array $filters): array { return $plain; }//end find() + /** + * Every row of one schema that holds all the asked values, each once. + * + * Asked with the filter first, then without: OpenRegister does not filter + * on every kind of property, so a register that ignores the filter hands + * over every row, and the match is checked here either way. Reads across + * organisations, so a business key two tenants share yields both rows. + * + * @param string $register The register slug. + * @param string $schema The schema slug. + * @param array $where The asked values, by field. + * + * @return array> The matching rows; empty when none matches. + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-every-placeholder-resolves-or-the-set-is-refused-req-exs-002 + */ + public function matching(string $register, string $schema, array $where): array { + foreach ([$where, []] as $filters) { + $found = []; + foreach ($this->find(register: $register, schema: $schema, filters: $filters) as $row) { + if ($this->rowMatches(row: $row, where: $where) === false) { + continue; + } + + $id = $this->idOf(row: $row); + if ($id === '') { + $id = 'row-'.count($found); + } + + $found[$id] = $row; + } + + if ($found !== []) { + return array_values($found); + } + } + + return []; + }//end matching() + + /** + * Whether a row holds every asked value. + * + * @param array $row The row. + * @param array $where The asked values, by field. + * + * @return bool + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-every-placeholder-resolves-or-the-set-is-refused-req-exs-002 + */ + public function rowMatches(array $row, array $where): bool { + foreach ($where as $field => $value) { + $held = ($row[$field] ?? null); + if ($held === $value) { + continue; + } + + if (is_scalar($held) === false || is_scalar($value) === false || (string) $held !== (string) $value) { + return false; + } + } + + return true; + }//end rowMatches() + /** * One row by its id, or null when it is not there. * + * Null is also what a read that failed gives; a caller that must tell + * the two apart uses {@see self::read()}. + * * @param string $register The register slug. * @param string $schema The schema slug. * @param string $id The row's id. @@ -179,24 +250,51 @@ public function find(string $register, string $schema, array $filters): array { * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-an-install-proves-what-arrived-req-exs-006 */ public function get(string $register, string $schema, string $id): ?array { + return $this->read(register: $register, schema: $schema, id: $id)['row']; + }//end get() + + /** + * One row by its id, and why it could not be read when that is not simply "it is not there". + * + * Only a row OpenRegister says does not exist (a miss, or null) is + * absent. A database error, a magic-table error or a schema that cannot + * be resolved says nothing about the row, so it comes back as an error: + * a caller must not read it as "the instance lost the object". + * + * @param string $register The register slug. + * @param string $schema The schema slug. + * @param string $id The row's id. + * + * @return array{row: array|null, error: string} The row with `id` set, or null with '' when it is + * not there, or null with why the read failed. + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-second-install-writes-nothing-req-exs-005 + */ + public function read(string $register, string $schema, string $id): array { $service = $this->objectService(); - if ($service === null || $id === '') { - return null; + if ($service === null) { + return ['row' => null, 'error' => 'OpenRegister\'s object service is not available']; + } + + if ($id === '') { + return ['row' => null, 'error' => '']; } try { $row = $service->find(id: $id, register: $register, schema: $schema, _rbac: false, _multitenancy: false); - } catch (Throwable) { - return null; + } catch (DoesNotExistException) { + return ['row' => null, 'error' => '']; + } catch (Throwable $e) { + return ['row' => null, 'error' => $e->getMessage()]; } $row = $this->plain(row: $row); if ($row === null || $this->idOf(row: $row) !== $id) { - return null; + return ['row' => null, 'error' => '']; } - return (['id' => $id] + $row); - }//end get() + return ['row' => (['id' => $id] + $row), 'error' => '']; + }//end read() /** * Write one row. @@ -282,11 +380,15 @@ public function deleteRecorded(string $register, string $schema, string $id): st return 'OpenRegister\'s object service is not available'; } - // A row that cannot be found is gone, as the record's other steps read it. + // Only a row OpenRegister says is not there is gone; a read that failed + // proves nothing, so the row stays in the record and the reason is named. try { $row = $service->find(id: $id, register: $register, schema: $schema, _rbac: false, _multitenancy: false); - } catch (Throwable) { + } catch (DoesNotExistException) { $row = null; + } catch (Throwable $e) { + $this->logger->warning('OpenRegister: example set read before delete failed', ['schema' => $schema, 'id' => $id, 'reason' => $e->getMessage()]); + return 'it could not be read: '.$e->getMessage(); } if ($row?->hasActiveLegalHold() === true) { diff --git a/lib/Service/File/DeleteFileHandler.php b/lib/Service/File/DeleteFileHandler.php index 16cbb58ef7..12b499fc31 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,19 @@ 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/lib/Service/File/FileBatchHandler.php b/lib/Service/File/FileBatchHandler.php index b0cf0b8648..ca6b2e9666 100644 --- a/lib/Service/File/FileBatchHandler.php +++ b/lib/Service/File/FileBatchHandler.php @@ -185,7 +185,12 @@ private function executeAction( $this->fileService->unpublishFile(object: $object, filePath: $fileId); break; case 'delete': - $this->fileService->deleteFile(file: $fileId, object: $object); + // FileService answers false for a file that is still there (an + // operation a listener aborted, such as the frozen-object guard): + // that is a failed delete, not a succeeded one. + if ($this->fileService->deleteFile(file: $fileId, object: $object) === false) { + throw new Exception('The file could not be deleted'); + } break; case 'label': $labels = $params['labels'] ?? []; diff --git a/lib/Service/File/ObjectFolderResponder.php b/lib/Service/File/ObjectFolderResponder.php index a156fc5d97..c3c997ce50 100644 --- a/lib/Service/File/ObjectFolderResponder.php +++ b/lib/Service/File/ObjectFolderResponder.php @@ -25,6 +25,7 @@ use InvalidArgumentException; use OCA\OpenRegister\Exception\ObjectFileAccessDeniedException; use OCA\OpenRegister\Exception\ObjectFolderConflictException; +use OCA\OpenRegister\Exception\ObjectStateWriteException; use OCP\AppFramework\Http\JSONResponse; use OCP\Files\NotFoundException; use OCP\IL10N; @@ -38,7 +39,9 @@ * gives, so a refusal never confirms the object exists; a reader who may not * change it gets 403. A path that could leave the folder or a name that cannot * be used is 400, a path or node not in the folder is 404, a taken name or a - * locked file is 409. Anything else is a 500 that names nothing internal. + * locked file is 409, and so is a change to the files of a frozen or archived + * object, which answers with the state, who, when and why. Anything else is a + * 500 that names nothing internal. * * @spec openspec/changes/object-folder-in-files-browser/specs/file-actions/spec.md#requirement-changing-an-objects-folder-and-its-subfolders-follows-the-objects-update-rule */ @@ -87,6 +90,10 @@ public function failure(Throwable $e): JSONResponse { return new JSONResponse(data: ['error' => $this->l10n->t('Not found')], statusCode: 404); } + if ($e instanceof ObjectStateWriteException === true) { + return new JSONResponse(data: $e->toResponseBody(), statusCode: ObjectStateWriteException::HTTP_STATUS); + } + if ($e instanceof ObjectFolderConflictException === true) { return new JSONResponse(data: ['error' => $this->conflictMessage(code: (int)$e->getCode())], statusCode: 409); } diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 9da7fa34c8..eeb20f14f0 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -2337,8 +2337,22 @@ public function moveFile(ObjectEntity $sourceObject, int $fileId, ObjectEntity $ // Copy first. $newFile = $this->copyFile(sourceObject: $sourceObject, fileId: $fileId, targetObject: $targetObject); - // Delete source. - $this->deleteFile(file: $fileId, object: $sourceObject); + // Delete source. A source that is still there afterwards (the + // frozen-object guard aborted the delete, which Nextcloud does not + // report) makes this a copy, not a move: the copy is taken back and the + // caller told, instead of leaving the file in two objects. + if ($this->deleteFile(file: $fileId, object: $sourceObject) === false) { + try { + $this->deleteFile(file: $newFile, object: $targetObject); + } catch (Exception $e) { + $this->logger->warning( + message: "[FileService] Could not take back the copy of file {$fileId} after the source could not be deleted: " . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + } + + throw new Exception('The file could not be moved: it could not be removed from the source object'); + } $sourceUuid = $sourceObject->getUuid(); $targetUuid = $targetObject->getUuid(); diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php index f7da616e9d..72f4b2d294 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); @@ -1087,7 +1101,10 @@ public function executeNode(FlowRun $run, array $flow, object $subject, string $ } if ($config !== []) { - $step['config'] = array_merge((array)($step['config'] ?? []), $config); + $step['config'] = array_merge( + (array)($step['config'] ?? []), + $this->callerConfigFor(stepType: (string)($step['type'] ?? ''), config: $config) + ); } $run->setStatus(FlowRun::STATUS_RUNNING); @@ -1135,6 +1152,54 @@ private function stepById(array $pinned, string $nodeId): ?array { return null; }//end stepById() + /** + * Keep only the caller's keys that the node declares it reads. + * + * The caller of a direct invocation may read the subject, which is not the + * same as being the flow's author: a key the node's own vocabulary + * ({@see IFlowNodeConfigKeys::configKeys()}, else the keys of its + * {@see IFlowNodeConfigForm}) does not list is one the author never meant + * to expose, so it never reaches the node. A node that declares neither + * takes nothing from the caller (fail closed): the published step's own + * config is all it runs with. + * + * @param string $stepType The published step's node type. + * @param array $config The caller's config. + * + * @return array The caller's config limited to the node's declared keys. + * + * @spec openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md#scenario-the-callers-config-reaches-the-node + */ + private function callerConfigFor(string $stepType, array $config): array { + try { + $node = $this->registry->get(type: $stepType); + } catch (Throwable) { + // The dispatcher refuses an unknown type itself; nothing of the caller's reaches it. + return []; + } + + $declared = null; + if ($node instanceof IFlowNodeConfigKeys) { + $declared = array_map(static fn ($key): string => (string)$key, $node->configKeys()); + } else if ($node instanceof IFlowNodeConfigForm) { + $declared = array_map( + static fn ($field): string => (string)(((array)$field)['key'] ?? ''), + $node->configForm() + ); + } + + if ($declared === null) { + $this->logger->warning( + message: '[FlowRunService] A direct-invoke caller sent config to a node that declares no config keys; it was ignored.', + context: ['file' => __FILE__, 'line' => __LINE__, 'type' => $stepType, 'keys' => array_keys($config)] + ); + + return []; + } + + return array_intersect_key($config, array_flip($declared)); + }//end callerConfigFor() + /** * Fail a run whose named node is not part of the graph it is pinned to. * diff --git a/lib/Service/Form/FormDependentsCheck.php b/lib/Service/Form/FormDependentsCheck.php index 321dc195a8..e064463355 100644 --- a/lib/Service/Form/FormDependentsCheck.php +++ b/lib/Service/Form/FormDependentsCheck.php @@ -62,6 +62,13 @@ class FormDependentsCheck { */ public const SUBJECT = 'form_unpublished'; + /** + * The schemas whose save a caller is judging itself, by id, for this request. + * + * @var array + */ + private array $claimed = []; + /** * Constructor. * @@ -81,6 +88,39 @@ public function __construct( }//end __construct() + /** + * Say that the caller of this save judges the dependent forms itself. + * + * The schema API assesses BEFORE the write, so it can refuse the save, and + * applies after it. Every other writer of a schema (the configuration + * importer, the import endpoint, the AI and MCP tools) has no response to + * refuse, and is covered by {@see \OCA\OpenRegister\Listener\FormDestinationSchemaListener}, which + * skips a save its caller has claimed so no form is unpublished or + * announced twice. + * + * @param int $schemaId The schema about to be saved. + * + * @return void + * + * @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 claim(int $schemaId): void { + $this->claimed[$schemaId] = true; + }//end claim() + + /** + * Whether the caller of this schema's save judges the dependent forms itself. + * + * @param int $schemaId The schema. + * + * @return bool True when it was claimed in this request. + * + * @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 isClaimed(int $schemaId): bool { + return isset($this->claimed[$schemaId]); + }//end isClaimed() + /** * Judge every published form that submits into the proposed schema. * @@ -151,13 +191,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/lib/Service/Form/FormDestinationValidator.php b/lib/Service/Form/FormDestinationValidator.php index ddb2bda716..13b07a3017 100644 --- a/lib/Service/Form/FormDestinationValidator.php +++ b/lib/Service/Form/FormDestinationValidator.php @@ -42,6 +42,7 @@ namespace OCA\OpenRegister\Service\Form; use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Service\Schemas\GeneratedIdentifierDeclaration; use OCP\IL10N; /** @@ -516,7 +517,7 @@ private function requiredFindings(Schema $schema, array $sourced): array { $findings = []; foreach (array_values(array_unique(array_map('strval', $required))) as $name) { $property = (array)($properties[$name] ?? []); - if (in_array($name, $sourced, true) === true || $this->isServerFilled(property: $property) === true) { + if (in_array($name, $sourced, true) === true || self::isServerFilled(property: $property) === true) { continue; } @@ -531,17 +532,29 @@ private function requiredFindings(Schema $schema, array $sourced): array { }//end requiredFindings() /** - * Whether the server fills a property: a default, a computation, or the serverSet marker. + * Whether the server fills a property: a default, a computation, a generated identifier, or the serverSet marker. + * + * The one definition of "no field needs to": the form's mapping check and + * the draft policy's `required` excusals both ask it, so a draft that one + * accepts the other accepts too. * * @param array $property The schema property. * * @return bool True when no field needs to. + * + * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-drafts-owner-must-be-able-to-make-it-definitive-through-the-object-api */ - private function isServerFilled(array $property): bool { + public static function isServerFilled(array $property): bool { if (array_key_exists('default', $property) === true || isset($property['computed']) === true) { return true; } + // An `x-openregister-generated` identifier is issued by the creating + // listener, and on the save that leaves draft (decision 227). + if (array_key_exists(GeneratedIdentifierDeclaration::ANNOTATION, $property) === true) { + return true; + } + return self::marker(property: $property, name: self::MARKER_SERVER_SET); }//end isServerFilled() diff --git a/lib/Service/Form/FormIdempotencyStore.php b/lib/Service/Form/FormIdempotencyStore.php index 7bdd3fef3d..987a68ab92 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,90 @@ 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. + * @param string $key The Idempotency-Key. + * + * @return void * - * @param string $scope The form scope (form id, or the caller's own). + * @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 + * + * @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 { + $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 +169,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 +187,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 +239,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 +247,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 a7f876b264..cc20fc31dc 100644 --- a/lib/Service/Form/FormSubmitService.php +++ b/lib/Service/Form/FormSubmitService.php @@ -47,6 +47,7 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Exception\CustomValidationException; +use OCA\OpenRegister\Exception\DraftAlreadyLeftException; use OCA\OpenRegister\Exception\FormSubmitRefusedException; use OCA\OpenRegister\Exception\NotAuthorizedException; use OCA\OpenRegister\Service\Object\DraftFinder; @@ -71,6 +72,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. @@ -156,7 +159,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 */ @@ -215,7 +218,7 @@ public function saveDraft( string $scope = '', ?DraftOwner $owner = null, ): array { - $owner = $this->ownerOf(subject: $subject, owner: $owner); + $owner = DraftOwner::resolve(subject: $subject, owner: $owner); if ($owner === null) { throw new FormSubmitRefusedException(message: $this->l10n->t('Please sign in to save this form and carry on later.'), status: 401); } @@ -228,6 +231,10 @@ public function saveDraft( $this->validateAll(plans: $plans, mode: self::MODE_DRAFT); $created = $this->writeAll(plans: $plans, subject: $owner->user(), status: ObjectEntity::STATUS_DRAFT, uuid: $draftId, owner: $owner); + // The files now sit on the draft: the tokens are spent, as after a submit, + // so they cannot be redeemed again for another object or a later save. + $this->uploads->claim(tokens: array_merge(...array_map(static fn (array $plan): array => $plan['tokens'], $plans))); + return ['id' => (string)$created[0]['object']->getUuid(), 'status' => ObjectEntity::STATUS_DRAFT]; }//end saveDraft() @@ -249,7 +256,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 +270,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. @@ -300,7 +355,7 @@ public function submitAll( */ private function writeOrPromote(array $plans, ?IUser $subject, ?string $draftId, ?DraftOwner $owner = null): array { if ($draftId !== null) { - return $this->promoteDraft(plans: $plans, owner: $this->ownerOf(subject: $subject, owner: $owner), draftId: $draftId); + return $this->promoteDraft(plans: $plans, owner: DraftOwner::resolve(subject: $subject, owner: $owner), draftId: $draftId); } $this->validateAll(plans: $plans); @@ -331,6 +386,12 @@ private function promoteDraft(array $plans, ?DraftOwner $owner, string $draftId) DraftStatusPolicy::allowPromotion(uuid: $draftId); try { $created = $this->writeAll(plans: $plans, subject: $owner?->user(), status: ObjectEntity::STATUS_ACTIVE, uuid: $draftId, owner: $owner); + // Two requests on one draft can both pass the "still a draft" read. + // The save that did not use the allowance found it already sent: it + // answers as for a draft that is gone and announces nothing. + if (DraftStatusPolicy::promotionWasUsed(uuid: $draftId) === false) { + throw new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404); + } } finally { DraftStatusPolicy::resetPromotions(); } @@ -366,7 +427,7 @@ public function isOwnerOfDraft(array $destination, string $draftId, ?IUser $subj return false; } - $owner = $this->ownerOf(subject: $subject, owner: $owner); + $owner = DraftOwner::resolve(subject: $subject, owner: $owner); return $this->drafts->own(register: $register, schema: $schema, draftId: $draftId, owner: $owner) !== null; }//end isOwnerOfDraft() @@ -394,26 +455,6 @@ private function draftData(array $plan, string $draftId, ?DraftOwner $owner): ar return DraftFinder::dataOf(object: $draft); }//end draftData() - /** - * The draft's owner: the asserted one, else the signed-in subject, else nobody. - * - * @param IUser|null $subject The signed-in subject. - * @param DraftOwner|null $owner The asserted owner. - * - * @return DraftOwner|null The owner, or null when there is none. - */ - private function ownerOf(?IUser $subject, ?DraftOwner $owner): ?DraftOwner { - if ($owner !== null) { - return $owner; - } - - if ($subject !== null) { - return DraftOwner::ofUser(user: $subject); - } - - return null; - }//end ownerOf() - /** * Read the owner's draft by id: its data, for resuming a form. * @@ -434,7 +475,7 @@ private function ownerOf(?IUser $subject, ?DraftOwner $owner): ?DraftOwner { public function readDraft(array $destination, string $draftId, ?IUser $subject, ?DraftOwner $owner = null): array { $register = $this->register(reference: $destination['register'] ?? null); $schema = $this->schema(reference: $destination['schema'] ?? null); - $owner = $this->ownerOf(subject: $subject, owner: $owner); + $owner = DraftOwner::resolve(subject: $subject, owner: $owner); $draft = $this->drafts->own(register: $register, schema: $schema, draftId: $draftId, owner: $owner); if ($draft === null) { throw new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404); @@ -460,7 +501,7 @@ public function readDraft(array $destination, string $draftId, ?IUser $subject, * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value */ public function findDraft(array $destination, ?IUser $subject, ?DraftOwner $owner = null, array $match = []): ?array { - $owner = $this->ownerOf(subject: $subject, owner: $owner); + $owner = DraftOwner::resolve(subject: $subject, owner: $owner); if ($owner === null) { return null; } @@ -530,7 +571,8 @@ private function plan(array $writes, array $payload, string $scope): array { * * Only mapped fields reach the object: the submitter does not choose which * properties a form writes. Without a mapping the payload is the object, - * minus `_` control keys and `@` metadata, as the objects endpoint does. + * minus `_` control keys, `@` metadata and the `id` / `uuid` that would + * name an existing object, as the objects endpoint does. * * @param array|null $mapping The mapping. * @param array $payload The payload. @@ -573,11 +615,21 @@ private function mapPayload(?array $mapping, array $payload, string $scope): arr } } + // A submit never chooses the uuid of the object it writes. The save + // takes `id` (or `@self.id`) as the uuid of an existing object and + // updates it, so a submitter who names one would write over it, with + // RBAC off when the owner is a scope value. The only way to name an + // existing object is the draft id, which DraftFinder::own() checked. + unset($object['id']); + if (is_array($object['@self'] ?? null) === true) { + unset($object['@self']['id']); + } + return [$object, $files, $tokens]; }//end mapPayload() /** - * Property => value: the mapped fields, or without a mapping the payload minus control and metadata keys. + * Property => value: the mapped fields, or without a mapping the payload minus control keys, metadata and the object's `id` / `uuid`. * * @param array|null $mapping The mapping. * @param array $payload The payload. @@ -588,9 +640,8 @@ private function pairsOf(?array $mapping, array $payload): array { $pairs = []; if ($mapping === null) { foreach ($payload as $name => $value) { - $name = (string)$name; - if (str_starts_with($name, '_') === false && str_starts_with($name, '@') === false) { - $pairs[$name] = $value; + if ($this->isPropertyKey(name: (string)$name) === true) { + $pairs[(string)$name] = $value; } } @@ -608,6 +659,17 @@ private function pairsOf(?array $mapping, array $payload): array { return $pairs; }//end pairsOf() + /** + * Whether a payload key can be a property of the object: not a control key, metadata, or the `id` / `uuid` that name an object. + * + * @param string $name The payload key. + * + * @return bool True when it may reach the object. + */ + private function isPropertyKey(string $name): bool { + return str_starts_with($name, '_') === false && str_starts_with($name, '@') === false && in_array($name, ['id', 'uuid'], true) === false; + }//end isPropertyKey() + /** * The upload token a payload value carries, or null. * @@ -648,6 +710,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, @@ -826,6 +894,10 @@ private function refusalFor(Throwable $exception, array $plan): FormSubmitRefuse return $exception; } + if ($exception instanceof DraftAlreadyLeftException) { + return new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404); + } + $write = null; if ($plan['named'] === true) { $write = $plan['as']; diff --git a/lib/Service/Form/FormUploadStore.php b/lib/Service/Form/FormUploadStore.php index df4b29257e..eb8251bc0d 100644 --- a/lib/Service/Form/FormUploadStore.php +++ b/lib/Service/Form/FormUploadStore.php @@ -35,6 +35,7 @@ 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\ITempManager; @@ -60,6 +61,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 +118,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', @@ -201,7 +217,7 @@ public function claim(array $tokens): void { }//end claim() /** - * Delete every expired, unclaimed token. + * Delete every expired, unclaimed token, and the bytes of an upload whose record was never written. * * @return int How many tokens were deleted, so a zero is a counted zero. * @@ -215,25 +231,58 @@ public function purge(): int { $now = $this->time->getTime(); $deleted = 0; + $gone = []; foreach ($folder->getDirectoryListing() as $file) { $name = $file->getName(); - if (str_ends_with($name, '.json') === false) { + if (str_ends_with($name, '.json') === true) { + $token = substr($name, 0, -5); + $meta = $this->meta(token: $token); + if ($meta !== null && $meta['expiresAt'] > $now) { + continue; + } + + $this->deleteToken(folder: $folder, token: $token); + $gone[$token] = true; + $deleted++; continue; } - $token = substr($name, 0, -5); - $meta = $this->meta(token: $token); - if ($meta !== null && $meta['expiresAt'] > $now) { - continue; + if ($this->isOrphanedBytes(folder: $folder, file: $file, gone: $gone, now: $now) === true) { + $file->delete(); + $deleted++; } - - $this->deleteToken(folder: $folder, token: $token); - $deleted++; } return $deleted; }//end purge() + /** + * Whether a file is the bytes of an upload whose record was never written, and old enough not to be in flight. + * The bytes are written first, so a failed second write leaves a `.bin` + * issue() writes the bytes first, so a failed second write leaves a `.bin` + * nothing else lists. One younger than a token's lifetime may still be an + * upload in flight, and is left alone. + * + * @param ISimpleFolder $folder The store folder. + * @param ISimpleFile $file A file of the folder. + * @param array $gone Tokens this purge already deleted. + * @param int $now The moment of the purge. + * + * @return bool True when the purge deletes it. + */ + private function isOrphanedBytes(ISimpleFolder $folder, ISimpleFile $file, array $gone, int $now): bool { + $name = $file->getName(); + if (str_ends_with($name, '.bin') === false) { + return false; + } + + $token = substr($name, 0, -4); + + return isset($gone[$token]) === false + && $folder->fileExists($token . '.json') === false + && ($file->getMTime() + self::TTL) <= $now; + }//end isOrphanedBytes() + /** * A token's record, or null when absent or unreadable. * diff --git a/lib/Service/Integration/CallSignature.php b/lib/Service/Integration/CallSignature.php new file mode 100644 index 0000000000..287882151f --- /dev/null +++ b/lib/Service/Integration/CallSignature.php @@ -0,0 +1,63 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://www.OpenRegister.app + * + * @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Integration; + +use ReflectionMethod; + +/** + * Reads the signature of a connector's `call`, so a caller can ask before it passes an option. + * + * The connector is another app and a different version of it is installed on + * every channel; PHP refuses an unknown named argument only when the method + * runs. Asked of the signature, the answer is the same for every call and no + * call is made to find out. + * + * @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies + */ +final class CallSignature { + + /** + * Whether `$service->$method()` names the parameter, or collects named arguments. + * + * @param object $service The connector's CallService. + * @param string $method The method asked about. + * @param string $name The parameter name. + * + * @return bool True when the method takes it. + * + * @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies + */ + public function accepts(object $service, string $method, string $name): bool { + if (method_exists($service, $method) === false) { + return false; + } + + foreach ((new ReflectionMethod($service, $method))->getParameters() as $parameter) { + if ($parameter->getName() === $name || $parameter->isVariadic() === true) { + return true; + } + } + + return false; + }//end accepts() +}//end class diff --git a/lib/Service/Integration/ExternalIntegrationRouter.php b/lib/Service/Integration/ExternalIntegrationRouter.php index ca38dcfa93..baff6da85d 100644 --- a/lib/Service/Integration/ExternalIntegrationRouter.php +++ b/lib/Service/Integration/ExternalIntegrationRouter.php @@ -54,6 +54,10 @@ * classifies the failure mode when something goes wrong. Per-provider * specifics (URL paths, payload shapes) live in the provider * implementations themselves. The router only provides the safe transport. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The transport meets the app + * manager, the container, the object service, the source entity, the fleet id + * resolver and the connector's call signature; each is one side of the join. */ class ExternalIntegrationRouter { use ConnectorRequestTrait; @@ -706,8 +710,10 @@ private function invokeWithMeta($source, string $method, string $path, array $op * * With the `withholdBodies` option the connector records the call without * its request and response bodies. A connector whose `call` has no such - * parameter is not called at all: PHP refuses an unknown named argument - * before the method runs, and the caller reports that as upstream-down. + * parameter is not called at all, and the call is refused with its own + * cause (`connector-cannot-withhold-bodies`) before PHP gets the chance to + * refuse the unknown named argument: that would read as an outage and be + * logged at error level on every call of an older connector. * * @param mixed $source Resolved source entity. * @param string $method HTTP method. @@ -717,6 +723,7 @@ private function invokeWithMeta($source, string $method, string $path, array $op * @return mixed The raw CallService response. * * @throws \RuntimeException When CallService is unreachable. + * @throws ProviderUnavailableException When bodies are to be withheld and the connector cannot. * * @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies */ @@ -725,6 +732,16 @@ private function dispatch($source, string $method, string $path, array $options) unset($options['withholdBodies']); $callService = $this->container->get($this->callServiceClass()); + if ($withholdBodies === true && (new CallSignature())->accepts(service: $callService, method: 'call', name: 'withholdBodies') === false) { + $this->logger->info( + '[ExternalIntegrationRouter] The connector CallService cannot withhold bodies; the call is not made.' + ); + throw new ProviderUnavailableException( + message: 'The connector cannot record a call without its bodies, so the call is not made.', + cause: ProviderUnavailableException::CAUSE_CONNECTOR_CANNOT_WITHHOLD_BODIES + ); + } + $options = $this->connectorOptions(options: $options); $path = $this->connectorEndpoint(source: $source, path: $path); diff --git a/lib/Service/Integration/Providers/BrpPersonProvider.php b/lib/Service/Integration/Providers/BrpPersonProvider.php index 528bd0a73d..e5ed9b9e9d 100644 --- a/lib/Service/Integration/Providers/BrpPersonProvider.php +++ b/lib/Service/Integration/Providers/BrpPersonProvider.php @@ -98,14 +98,6 @@ class BrpPersonProvider extends AbstractIntegrationProvider { // go through FleetAppId, or the comparison is false on half the fleet. private const REQUIRED_APP = 'integriq'; - /** - * Default field set requested from HaalCentraal. The BRP API requires an - * explicit `fields` list on every query. Mirrors the field set pipelinq's - * `HaalCentraalClient` requests so the consuming app's normaliser receives - * the subtrees it expects. - * - * @var array - */ /** * The source configuration flag that allows the residents count. */ @@ -121,6 +113,14 @@ class BrpPersonProvider extends AbstractIntegrationProvider { */ public const CAUSE_INVALID_IDENTIFIER = 'invalid-identifier'; + /** + * Default field set requested from HaalCentraal. The BRP API requires an + * explicit `fields` list on every query. Mirrors the field set pipelinq's + * `HaalCentraalClient` requests so the consuming app's normaliser receives + * the subtrees it expects. + * + * @var array + */ private const DEFAULT_FIELDS = [ 'burgerservicenummer', 'naam.voornamen', diff --git a/lib/Service/MigrationPack/MappingEngine.php b/lib/Service/MigrationPack/MappingEngine.php index ea18a97cd0..e94bfed373 100644 --- a/lib/Service/MigrationPack/MappingEngine.php +++ b/lib/Service/MigrationPack/MappingEngine.php @@ -40,6 +40,7 @@ namespace OCA\OpenRegister\Service\MigrationPack; use DateTime; +use OCP\IL10N; /** * Maps one source row (CSV row / Excel row / decoded JSON object) onto a set @@ -48,6 +49,21 @@ * @spec openspec/specs/migration-mapping-packs/spec.md */ class MappingEngine { + /** + * Constructor. + * + * The row errors this engine returns are shown to the person running the + * import or the editor's preview, so each message goes through the app's + * translations; the values inside them stay as found. + * + * @param IL10N $l10n The app's translations. + */ + public function __construct( + private readonly IL10N $l10n, + ) { + + }//end __construct() + /** * Apply a pack definition to one source row. * @@ -82,7 +98,7 @@ public function mapRow(array $pack, array $sourceRow, int $rowNumber): array { 'source' => $source, 'target' => $target, 'transform' => $transformId, - 'message' => sprintf('Required source field "%s" is missing or empty', $source), + 'message' => $this->l10n->t('Required source field "%s" is missing or empty', [$source]), ]; continue; } @@ -232,7 +248,7 @@ private function applyTransform($value, ?array $transform, array $sourceRow): ar case 'const': return ['value' => ($transform['value'] ?? null), 'error' => null]; default: - return ['value' => null, 'error' => 'Unknown transform type "' . (string)($transform['type'] ?? '') . '"']; + return ['value' => null, 'error' => $this->l10n->t('Unknown transform type "%s"', [(string)($transform['type'] ?? '')])]; }//end switch }//end applyTransform() @@ -260,7 +276,7 @@ private function applyDateTransform($value, array $transform): array { if ($date === false) { return [ 'value' => null, - 'error' => sprintf('Could not parse date "%s" with format "%s"', $stringValue, $sourceFormat), + 'error' => $this->l10n->t('Could not parse date "%1$s" with format "%2$s"', [$stringValue, $sourceFormat]), ]; } } @@ -269,7 +285,7 @@ private function applyDateTransform($value, array $transform): array { $date = new DateTime($stringValue); } } catch (\Throwable $e) { - return ['value' => null, 'error' => sprintf('Could not parse date "%s": %s', $stringValue, $e->getMessage())]; + return ['value' => null, 'error' => $this->l10n->t('Could not parse date "%1$s": %2$s', [$stringValue, $e->getMessage()])]; } return ['value' => $date->format($targetFormat), 'error' => null]; @@ -313,7 +329,7 @@ private function applyMapTransform($value, array $transform, bool $coerceBool): // through unmapped into the target schema property. return [ 'value' => null, - 'error' => sprintf('Value "%s" has no mapping and no default is configured', $key), + 'error' => $this->l10n->t('Value "%s" has no mapping and no default is configured', [$key]), ]; }//end applyMapTransform() diff --git a/lib/Service/MigrationPack/MigrationPackRequestReader.php b/lib/Service/MigrationPack/MigrationPackRequestReader.php index e449b3f42e..844d6df089 100644 --- a/lib/Service/MigrationPack/MigrationPackRequestReader.php +++ b/lib/Service/MigrationPack/MigrationPackRequestReader.php @@ -139,8 +139,9 @@ public function definition(IRequest $request): array { unset($definition['id']); $errors = $this->validator->validate(definition: $definition); - $errors[] = 'An "id" in the request body that equals the row id in the URL cannot be told apart from it.' - . ' Send the pack as {"definition": {...}} to keep that id.'; + $errors[] = $this->l10n->t('An "id" in the request body that equals the row id in the URL cannot be told apart from it.') + . ' ' + . $this->l10n->t('Send the pack as {"definition": {...}} to keep that id.'); throw new InvalidPackDefinitionException(errors: $errors); }//end definition() diff --git a/lib/Service/MigrationPack/PackDefinitionValidator.php b/lib/Service/MigrationPack/PackDefinitionValidator.php index c143e4040f..e9936b238d 100644 --- a/lib/Service/MigrationPack/PackDefinitionValidator.php +++ b/lib/Service/MigrationPack/PackDefinitionValidator.php @@ -34,6 +34,7 @@ namespace OCA\OpenRegister\Service\MigrationPack; use OCA\OpenRegister\Exception\InvalidPackDefinitionException; +use OCP\IL10N; /** * Structural + business-rule validator for a migration-pack JSON document. @@ -65,6 +66,21 @@ class PackDefinitionValidator { */ public const ALLOWED_ID_STRATEGY_TYPES = ['sourceField', 'generate']; + /** + * Constructor. + * + * Every message is shown to the person editing the pack (the pack editor + * lists them under the save error), so each goes through the app's + * translations; the property names and values inside them stay as written. + * + * @param IL10N $l10n The app's translations. + */ + public function __construct( + private readonly IL10N $l10n, + ) { + + }//end __construct() + /** * Validate a pack definition document. * @@ -117,11 +133,11 @@ public function assertValid(array $definition): void { private function validateId(array $definition): array { $id = $definition['id'] ?? null; if (is_string($id) === false || $id === '') { - return ['"id" is required and must be a non-empty string']; + return [$this->l10n->t('"%s" is required and must be a non-empty string', ['id'])]; } if (preg_match('/^[a-z0-9][a-z0-9-]*$/', $id) !== 1) { - return ['"id" must be a lowercase slug (letters, digits, hyphens), got "' . $id . '"']; + return [$this->l10n->t('"id" must be a lowercase slug (letters, digits, hyphens), got "%s"', [$id])]; } return []; @@ -137,7 +153,7 @@ private function validateId(array $definition): array { private function validateName(array $definition): array { $name = $definition['name'] ?? null; if (is_string($name) === false || $name === '') { - return ['"name" is required and must be a non-empty string']; + return [$this->l10n->t('"%s" is required and must be a non-empty string', ['name'])]; } return []; @@ -154,8 +170,10 @@ private function validateSourceFormat(array $definition): array { $format = $definition['sourceFormat'] ?? null; if (is_string($format) === false || in_array($format, self::ALLOWED_SOURCE_FORMATS, true) === false) { return [ - '"sourceFormat" must be one of: ' . implode(', ', self::ALLOWED_SOURCE_FORMATS) - . ' (got ' . var_export($format, true) . ')', + $this->l10n->t( + '%1$s must be one of: %2$s (got %3$s)', + ['"sourceFormat"', implode(', ', self::ALLOWED_SOURCE_FORMATS), var_export($format, true)] + ), ]; } @@ -172,7 +190,7 @@ private function validateSourceFormat(array $definition): array { private function validateVersion(array $definition): array { $version = $definition['version'] ?? null; if (is_string($version) === false || preg_match('/^\d+\.\d+\.\d+$/', $version) !== 1) { - return ['"version" must be a semver string (MAJOR.MINOR.PATCH), got ' . var_export($version, true)]; + return [$this->l10n->t('"version" must be a semver string (MAJOR.MINOR.PATCH), got %s', [var_export($version, true)])]; } return []; @@ -197,33 +215,36 @@ private function validateVersion(array $definition): array { private function validateFieldMappings(array $definition): array { $mappings = $definition['fieldMappings'] ?? null; if (is_array($mappings) === false || empty($mappings) === true) { - return ['"fieldMappings" is required and must be a non-empty array']; + return [$this->l10n->t('"fieldMappings" is required and must be a non-empty array')]; } $errors = []; foreach (array_values($mappings) as $position => $mapping) { - $row = 'Mapping row ' . ($position + 1); + $number = ($position + 1); if (is_array($mapping) === false) { - $errors[] = $row . ' must be an object'; + $errors[] = $this->l10n->t('Mapping row %s must be an object', [$number]); continue; } $source = $mapping['source'] ?? null; if (is_string($source) === false || $source === '') { - $errors[] = $row . ': source is required and must be a non-empty string'; + $errors[] = $this->l10n->t('Mapping row %1$s: %2$s is required and must be a non-empty string', [$number, 'source']); } $target = $mapping['target'] ?? null; if (is_string($target) === false || $target === '') { - $errors[] = $row . ': target is required and must be a non-empty string'; + $errors[] = $this->l10n->t('Mapping row %1$s: %2$s is required and must be a non-empty string', [$number, 'target']); } if (isset($mapping['required']) === true && is_bool($mapping['required']) === false) { - $errors[] = $row . ': required must be a boolean when present'; + $errors[] = $this->l10n->t('Mapping row %s: required must be a boolean when present', [$number]); } if (isset($mapping['transform']) === true) { - $errors = array_merge($errors, $this->validateTransform(transform: $mapping['transform'], path: $row . ': transform')); + $errors = array_merge( + $errors, + $this->validateTransform(transform: $mapping['transform'], path: $this->l10n->t('Mapping row %s: transform', [$number])) + ); } }//end foreach @@ -243,55 +264,57 @@ private function validateFieldMappings(array $definition): array { */ private function validateTransform($transform, string $path): array { if (is_array($transform) === false) { - return [$path . ' must be an object with a "type" key']; + return [$this->l10n->t('%s must be an object with a "type" key', [$path])]; } $type = $transform['type'] ?? null; if (is_string($type) === false || in_array($type, self::ALLOWED_TRANSFORM_TYPES, true) === false) { return [ - $path . '.type must be one of: ' . implode(', ', self::ALLOWED_TRANSFORM_TYPES) - . ' (got ' . var_export($type, true) . ')', + $this->l10n->t( + '%1$s must be one of: %2$s (got %3$s)', + [$path . '.type', implode(', ', self::ALLOWED_TRANSFORM_TYPES), var_export($type, true)] + ), ]; } switch ($type) { case 'date': if (isset($transform['sourceFormat']) === true && is_string($transform['sourceFormat']) === false) { - return [$path . '.sourceFormat must be a string when present']; + return [$this->l10n->t('%s must be a string when present', [$path . '.sourceFormat'])]; } if (isset($transform['targetFormat']) === true && is_string($transform['targetFormat']) === false) { - return [$path . '.targetFormat must be a string when present']; + return [$this->l10n->t('%s must be a string when present', [$path . '.targetFormat'])]; } break; case 'bool-map': if (is_array($transform['map'] ?? null) === false || empty($transform['map']) === true) { - return [$path . '.map is required and must be a non-empty object for a bool-map transform']; + return [$this->l10n->t('%1$s is required and must be a non-empty object for a %2$s transform', [$path . '.map', 'bool-map'])]; } break; case 'lookup': if (is_array($transform['map'] ?? null) === false || empty($transform['map']) === true) { - return [$path . '.map is required and must be a non-empty object for a lookup transform']; + return [$this->l10n->t('%1$s is required and must be a non-empty object for a %2$s transform', [$path . '.map', 'lookup'])]; } break; case 'concat': if (is_array($transform['fields'] ?? null) === false || empty($transform['fields']) === true) { - return [$path . '.fields is required and must be a non-empty array for a concat transform']; + return [$this->l10n->t('%1$s is required and must be a non-empty array for a %2$s transform', [$path . '.fields', 'concat'])]; } foreach ($transform['fields'] as $fieldIndex => $field) { if (is_string($field) === false || $field === '') { - return [$path . '.fields[' . $fieldIndex . '] must be a non-empty string']; + return [$this->l10n->t('%s must be a non-empty string', [$path . '.fields[' . $fieldIndex . ']'])]; } } break; case 'const': if (array_key_exists('value', $transform) === false) { - return [$path . '.value is required for a const transform']; + return [$this->l10n->t('%1$s is required for a %2$s transform', [$path . '.value', 'const'])]; } break; @@ -317,7 +340,7 @@ private function validateDefaults(array $definition): array { } if (is_array($definition['defaults']) === false) { - return ['"defaults" must be an object of target-property => default value when present']; + return [$this->l10n->t('"defaults" must be an object of target-property => default value when present')]; } return []; @@ -336,12 +359,12 @@ private function validateSkipRows(array $definition): array { } if (is_array($definition['skipRows']) === false) { - return ['"skipRows" must be an array of row numbers when present']; + return [$this->l10n->t('"skipRows" must be an array of row numbers when present')]; } foreach ($definition['skipRows'] as $row) { if (is_int($row) === false || $row < 1) { - return ['"skipRows" entries must be positive integers']; + return [$this->l10n->t('"skipRows" entries must be positive integers')]; } } @@ -358,21 +381,23 @@ private function validateSkipRows(array $definition): array { private function validateIdStrategy(array $definition): array { $idStrategy = $definition['idStrategy'] ?? null; if (is_array($idStrategy) === false) { - return ['"idStrategy" is required and must be an object with a "type" key']; + return [$this->l10n->t('"idStrategy" is required and must be an object with a "type" key')]; } $type = $idStrategy['type'] ?? null; if (is_string($type) === false || in_array($type, self::ALLOWED_ID_STRATEGY_TYPES, true) === false) { return [ - 'idStrategy.type must be one of: ' . implode(', ', self::ALLOWED_ID_STRATEGY_TYPES) - . ' (got ' . var_export($type, true) . ')', + $this->l10n->t( + '%1$s must be one of: %2$s (got %3$s)', + ['idStrategy.type', implode(', ', self::ALLOWED_ID_STRATEGY_TYPES), var_export($type, true)] + ), ]; } if ($type === 'sourceField' && (is_string($idStrategy['field'] ?? null) === false || $idStrategy['field'] === '') ) { - return ['idStrategy.field is required and must be a non-empty string when idStrategy.type is "sourceField"']; + return [$this->l10n->t('idStrategy.field is required and must be a non-empty string when idStrategy.type is "sourceField"')]; } return []; diff --git a/lib/Service/Notification/AnnotationNotificationDispatcher.php b/lib/Service/Notification/AnnotationNotificationDispatcher.php index 501df9692c..fde0a061c0 100644 --- a/lib/Service/Notification/AnnotationNotificationDispatcher.php +++ b/lib/Service/Notification/AnnotationNotificationDispatcher.php @@ -581,6 +581,7 @@ public function dispatchWithSchema(ObjectEntity $object, string $trigger, array $partiesReached += $this->dispatchToAddresses( recipientsSpec: (array)($spec['recipients'] ?? []), object: $object, + schema: $schema, data: $data, channels: $channels, ruleId: (string)$name, @@ -3341,6 +3342,7 @@ private function dispatchToParties( * * @param array $recipientsSpec The rule's `recipients` declaration. * @param ObjectEntity $object The triggering object. + * @param Schema $schema The triggering object's schema. * @param array $data Its data. * @param array $channels The rule's channels. * @param string $ruleId The rule, for the history row. @@ -3355,6 +3357,7 @@ private function dispatchToParties( private function dispatchToAddresses( array $recipientsSpec, ObjectEntity $object, + Schema $schema, array $data, array $channels, string $ruleId, @@ -3384,7 +3387,8 @@ private function dispatchToAddresses( recipientsSpec: $recipientsSpec, subject: $subject, body: $body, - category: $category + category: $category, + schema: $schema ); $reached = 0; diff --git a/lib/Service/Notification/ReferencedAddressNotifier.php b/lib/Service/Notification/ReferencedAddressNotifier.php index db136f19ef..ce58a88828 100644 --- a/lib/Service/Notification/ReferencedAddressNotifier.php +++ b/lib/Service/Notification/ReferencedAddressNotifier.php @@ -29,6 +29,9 @@ namespace OCA\OpenRegister\Service\Notification; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Object\SaveObject\RelationCascadeHandler; use OCA\OpenRegister\Service\ObjectService; use Psr\Log\LoggerInterface; @@ -39,7 +42,12 @@ * fired the rule: a melder may not be allowed to read the supplier, and the * rule still has to reach it. The read is bounded to the triggering object's * own organisation instead, so a reference can never pull an address out of - * another tenant. + * another tenant. It must also be an object of the schema the reference + * property points at (`$ref`): the value is data the person saving the + * triggering object controls, so without that check it could be aimed at any + * same-organisation object that happens to carry the named field. A reference + * property that declares no `$ref` names no schema to check against and is + * refused. * * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 */ @@ -88,16 +96,20 @@ class ReferencedAddressNotifier { /** * Constructor. * - * @param ObjectService $objects The object layer, for the referenced object. - * @param EmailSender $email The email channel. - * @param OptOutAuthority $optOut Asks integriq whether an address may be mailed. - * @param LoggerInterface $logger Logger. + * @param ObjectService $objects The object layer, for the referenced object. + * @param EmailSender $email The email channel. + * @param OptOutAuthority $optOut Asks integriq whether an address may be mailed. + * @param LoggerInterface $logger Logger. + * @param SchemaMapper $schemas The schemas, for the properties of a referenced object. + * @param RelationCascadeHandler $refs Resolves a property's `$ref` to a schema id. */ public function __construct( private readonly ObjectService $objects, private readonly EmailSender $email, private readonly OptOutAuthority $optOut, private readonly LoggerInterface $logger, + private readonly SchemaMapper $schemas, + private readonly RelationCascadeHandler $refs, ) { }//end __construct() @@ -114,6 +126,7 @@ public function __construct( * @param string $subject The rule's subject. * @param string $body The rule's message. * @param string $category The rule's message category. + * @param Schema|null $schema The triggering object's schema, which declares what each reference may point at. * * @return array One outcome per `email` entry. * @@ -126,6 +139,7 @@ public function notify( string $subject, string $body, string $category, + ?Schema $schema = null, ): array { $entries = []; foreach ($recipientsSpec as $recipient) { @@ -134,7 +148,7 @@ public function notify( } $path = trim((string)($recipient['field'] ?? '')); - $entries[] = ['field' => $path] + $this->resolveAddress(object: $object, data: $data, path: $path); + $entries[] = ['field' => $path] + $this->resolveAddress(object: $object, data: $data, path: $path, schema: $schema); } if ($entries === []) { @@ -172,24 +186,27 @@ public function notify( * Each segment but the last is read from the current data; when its value * is a reference (a uuid string, or an object carrying `id` or `uuid`) the * referenced object's data becomes the current data. The last segment is - * the address. + * the address. A referenced object must be of the schema the reference + * property's `$ref` names; without a schema to read that from, no + * reference is followed. * * @param ObjectEntity $object The triggering object. * @param array $data Its data. * @param string $path The dotted path, e.g. `supplierRef.contactEmail`. + * @param Schema|null $schema The triggering object's schema. * * @return array{address: string|null, reason: string|null} * * @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007 */ - public function resolveAddress(ObjectEntity $object, array $data, string $path): array { + public function resolveAddress(ObjectEntity $object, array $data, string $path, ?Schema $schema = null): array { $segments = array_values(array_filter(explode('.', $path), static fn (string $segment): bool => $segment !== '')); if ($segments === [] || count($segments) > (self::MAX_HOPS + 1)) { return ['address' => null, 'reason' => self::REASON_NO_REFERENCE]; } $last = array_pop($segments); - $followed = $this->follow(object: $object, data: $data, segments: $segments); + $followed = $this->follow(object: $object, data: $data, segments: $segments, schema: $schema); if ($followed['reason'] !== null) { return ['address' => null, 'reason' => $followed['reason']]; } @@ -213,11 +230,13 @@ public function resolveAddress(ObjectEntity $object, array $data, string $path): * @param ObjectEntity $object The triggering object. * @param array $data Its data. * @param array $segments The reference segments, in order. + * @param Schema|null $schema The triggering object's schema. * * @return array{data: array, reason: string|null} The data reached, or why it was not. */ - private function follow(ObjectEntity $object, array $data, array $segments): array { + private function follow(ObjectEntity $object, array $data, array $segments, ?Schema $schema): array { $current = $data; + $currentSchema = $schema; foreach ($segments as $segment) { $reference = $this->referenceOf(value: ($current[$segment] ?? null)); if ($reference === null) { @@ -233,7 +252,12 @@ private function follow(ObjectEntity $object, array $data, array $segments): arr return ['data' => [], 'reason' => self::REASON_OTHER_TENANT]; } + if ($this->isOfDeclaredSchema(schema: $currentSchema, property: $segment, related: $related) === false) { + return ['data' => [], 'reason' => self::REASON_REFERENCE_NOT_FOUND]; + } + $current = $related->getObject(); + $currentSchema = $this->schemaOf(object: $related); } return ['data' => $current, 'reason' => null]; @@ -314,6 +338,68 @@ private function findReferenced(string $id): ?ObjectEntity { } }//end findReferenced() + /** + * Whether a referenced object is of the schema its reference property points at. + * + * The property's `$ref` is the only declaration of what the reference may + * name, so a property without one, or one that does not resolve to a schema, + * accepts nothing: the value is data the person saving the object controls. + * + * @param Schema|null $schema The schema that declares the property. + * @param string $property The reference property. + * @param ObjectEntity $related The referenced object. + * + * @return bool + */ + private function isOfDeclaredSchema(?Schema $schema, string $property, ObjectEntity $related): bool { + $declared = (($schema?->getProperties() ?? [])[$property] ?? null); + $ref = null; + if (is_array($declared) === true) { + $ref = ($declared['$ref'] ?? null); + } + + if (is_string($ref) === false || trim($ref) === '') { + $this->logger->debug( + sprintf('[ReferencedAddressNotifier] property "%s" declares no $ref, so its reference is not followed', $property) + ); + return false; + } + + $expected = $this->refs->resolveSchemaReference(reference: trim($ref)); + $actual = (string)($related->getSchema() ?? ''); + if ($expected === null || $actual === '' || $expected !== $actual) { + $this->logger->debug( + sprintf('[ReferencedAddressNotifier] reference "%s" is not an object of the schema "%s" declares', $property, $ref) + ); + return false; + } + + return true; + }//end isOfDeclaredSchema() + + /** + * The schema of a referenced object, for the next hop; null when it cannot be read. + * + * @param ObjectEntity $object The referenced object. + * + * @return Schema|null + */ + private function schemaOf(ObjectEntity $object): ?Schema { + $id = (string)($object->getSchema() ?? ''); + if ($id === '') { + return null; + } + + try { + return $this->schemas->find(id: $id, _rbac: false, _multitenancy: false); + } catch (\Throwable $e) { + $this->logger->debug( + sprintf('[ReferencedAddressNotifier] schema "%s" could not be read: %s', $id, $e->getMessage()) + ); + return null; + } + }//end schemaOf() + /** * Whether the referenced object may be read for the triggering one. * diff --git a/lib/Service/Object/DraftActivation.php b/lib/Service/Object/DraftActivation.php index b2a74981b7..ad2389cad2 100644 --- a/lib/Service/Object/DraftActivation.php +++ b/lib/Service/Object/DraftActivation.php @@ -45,6 +45,7 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Exception\CustomValidationException; use OCA\OpenRegister\Exception\DraftActivationRefusedException; +use OCA\OpenRegister\Exception\DraftAlreadyLeftException; use OCA\OpenRegister\Exception\NotAuthorizedException; use OCA\OpenRegister\Service\Form\FormPayloadFindings; use OCA\OpenRegister\Service\ObjectService; @@ -131,6 +132,12 @@ public function activate(Register $register, Schema $schema, string $draftId, Dr _multitenancy: true, currentUser: $owner->user() ); + // Two requests on one draft can both pass the "still a draft" read. + // The save that did not use the allowance found it already sent: + // it announces nothing, and answers as for an object that is no draft. + if (DraftStatusPolicy::promotionWasUsed(uuid: $uuid) === false) { + throw $this->alreadyLeft(); + } } catch (Throwable $exception) { throw $this->refusalFor(exception: $exception); } finally { @@ -257,6 +264,10 @@ private function validateInFull(Schema $schema, array $object): void { * @throws Throwable When the save failed for a reason that is not the caller's to fix. */ private function refusalFor(Throwable $exception): DraftActivationRefusedException { + if ($exception instanceof DraftAlreadyLeftException) { + return $this->alreadyLeft(); + } + if ($this->findings->isPayloadRefusal(exception: $exception) === true) { return $this->invalid(findings: $this->findings->fromThrowable(exception: $exception)); } @@ -271,6 +282,18 @@ private function refusalFor(Throwable $exception): DraftActivationRefusedExcepti throw $exception; }//end refusalFor() + /** + * The 409 refusal for a draft a parallel request already made definitive. + * + * @return DraftActivationRefusedException The refusal. + */ + private function alreadyLeft(): DraftActivationRefusedException { + return new DraftActivationRefusedException( + message: $this->l10n->t('This is not a draft, so there is nothing to make definitive.'), + reason: DraftActivationRefusedException::NOT_A_DRAFT + ); + }//end alreadyLeft() + /** * The 422 refusal. * diff --git a/lib/Service/Object/DraftExpiry.php b/lib/Service/Object/DraftExpiry.php index da142535b4..4ab9a4ef61 100644 --- a/lib/Service/Object/DraftExpiry.php +++ b/lib/Service/Object/DraftExpiry.php @@ -17,7 +17,10 @@ * referenced object) keeps the draft and is counted as refused. * * The work of one run is bounded: at most MAX_REMOVALS drafts are removed, - * the rest is counted as deferred and waits for the next run. + * and at most MAX_PAGES x PAGE_SIZE expired drafts are read per register and + * schema pair. The list asks the store for the expired drafts alone, so the + * rows read are expired rows, and what is left after the cap (removals and + * reads alike) is counted as deferred and waits for the next run. * * Nothing here names an app or a procedure (decision 182). * @@ -163,22 +166,26 @@ private function walk(DateTimeImmutable $now, int $budget): array { foreach (($register->getSchemas() ?? []) as $schemaId) { $schema = $this->schemaOf(schemaId: $schemaId); - if ($schema === null || $this->holdsDrafts(schema: $schema) === false) { - continue; - } - - $days = $policy->draftLifetimeDays(schema: $schema); + $days = $this->lifetimeOf(schema: $schema, policy: $policy); if ($days === null) { continue; } $counts['schemas']++; - $expired = $this->expiredIn(register: $register, schema: $schema, cutoff: $now->modify('-' . $days . ' days')); - if ($expired === []) { + $cutoff = $now->modify('-' . $days . ' days'); + [$expired, $unread] = $this->expiredIn(register: $register, schema: $schema, cutoff: $cutoff); + if ($expired === [] && $unread === 0) { 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, + unread: $unread + ); $row['lifetimeDays'] = $days; $counts['perSchema'][] = $row; foreach (['expired', 'removed', 'refused', 'deferred'] as $key) { @@ -190,6 +197,22 @@ private function walk(DateTimeImmutable $now, int $budget): array { return $counts; }//end walk() + /** + * How many days the drafts of a schema may go unchanged, or null when the sweep leaves the schema alone. + * + * @param Schema|null $schema The schema, null when it could not be loaded. + * @param DraftStatusPolicy $policy The lifetime rules. + * + * @return int|null The days. + */ + private function lifetimeOf(?Schema $schema, DraftStatusPolicy $policy): ?int { + if ($schema === null || $this->holdsDrafts(schema: $schema) === false) { + return null; + } + + return $policy->draftLifetimeDays(schema: $schema); + }//end lifetimeOf() + /** * Whether a schema stores objects of its own, so it can hold a draft. * @@ -211,19 +234,21 @@ 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. + * @param int $unread How many expired drafts lie beyond what one run reads; they wait for the next run. * * @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, int $unread=0): array { $row = [ 'register' => (string)($register->getSlug() ?? $register->getId()), 'schema' => (string)($schema->getSlug() ?? $schema->getId()), 'lifetimeDays' => 0, - 'expired' => count($expired), + 'expired' => (count($expired) + $unread), 'removed' => 0, 'refused' => 0, - 'deferred' => 0, + 'deferred' => $unread, ]; foreach ($expired as $uuid) { @@ -232,7 +257,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; } @@ -268,29 +293,79 @@ private function schemaOf(mixed $schemaId): ?Schema { }//end schemaOf() /** - * The uuids of the drafts in one register and schema that nobody changed since the cutoff. + * The uuids of the drafts in one register and schema that nobody changed since the cutoff, and how many more lie beyond the read cap. * - * Read in full before anything is deleted, so a delete never shifts the - * page the next read asks for. + * The store is asked for the expired drafts alone (last changed before the + * cutoff, or never changed and created before it), so the rows one run + * reads are expired rows and a schema full of young drafts cannot hide an + * old one behind them. Read in full before anything is deleted, so a + * delete never shifts the page the next read asks for. When the cap of + * MAX_PAGES full pages is reached, the rest is counted, not read. * * @param Register $register The register. * @param Schema $schema The schema. * @param DateTimeImmutable $cutoff Drafts last changed before this moment are expired. * - * @return array The uuids. + * @return array{0: array, 1: int} The uuids, and the number of expired drafts left unread. */ private function expiredIn(Register $register, Schema $schema, DateTimeImmutable $cutoff): array { + $moment = gmdate('Y-m-d H:i:s', $cutoff->getTimestamp()); + $filters = [ + // Changed at some point, and not since the cutoff. + ['updated' => ['lt' => $moment]], + // Never changed: the created moment counts. + ['updated' => 'IS NULL', 'created' => ['lt' => $moment]], + ]; + $expired = []; - for ($page = 0; $page < self::MAX_PAGES; $page++) { + $unread = 0; + $pages = self::MAX_PAGES; + foreach ($filters as $filter) { + $query = [ + '@self' => (['register' => (string)$register->getId(), 'schema' => (string)$schema->getId()] + $filter), + // Drafts alone, whoever owns them (decision 201's lens). + DraftListLens::PARAMETER => 'true', + ]; + if ($pages <= 0) { + $unread += $this->countOf(register: $register, schema: $schema, query: $query); + continue; + } + + $scan = $this->scan(register: $register, schema: $schema, query: $query, cutoff: $cutoff, pages: $pages); + array_push($expired, ...$scan['uuids']); + if ($scan['failed'] === true) { + // A list that cannot be read is logged once; the schema's drafts wait for the next run. + break; + } + + $pages -= $scan['pages']; + if ($scan['full'] === true) { + $unread += max(1, ($this->countOf(register: $register, schema: $schema, query: $query) - $scan['rows'])); + } + } + + return [array_values(array_unique($expired)), $unread]; + }//end expiredIn() + + /** + * Read the pages of one list, up to a page budget. + * + * @param Register $register The register. + * @param Schema $schema The schema. + * @param array $query The list query, without paging. + * @param DateTimeImmutable $cutoff Drafts last changed before this moment are expired. + * @param int $pages How many pages may be read. + * + * @return array{uuids: array, pages: int, rows: int, full: bool, failed: bool} + * The expired uuids, the pages and rows read, whether the last page was full when the pages ran out, + * and whether a read failed. + */ + private function scan(Register $register, Schema $schema, array $query, DateTimeImmutable $cutoff, int $pages): array { + $scan = ['uuids' => [], 'pages' => 0, 'rows' => 0, 'full' => false, 'failed' => false]; + for ($page = 0; $page < $pages; $page++) { try { $found = $this->objects->searchObjects( - query: [ - '@self' => ['register' => (string)$register->getId(), 'schema' => (string)$schema->getId()], - '_limit' => self::PAGE_SIZE, - '_offset' => ($page * self::PAGE_SIZE), - // Drafts alone, whoever owns them (decision 201's lens). - DraftListLens::PARAMETER => 'true', - ], + query: ($query + ['_limit' => self::PAGE_SIZE, '_offset' => ($page * self::PAGE_SIZE)]), _rbac: false, _multitenancy: false ); @@ -299,26 +374,55 @@ private function expiredIn(Register $register, Schema $schema, DateTimeImmutable message: '[DraftExpiry] The drafts of a schema could not be listed', context: ['register' => $register->getSlug(), 'schema' => $schema->getSlug(), 'exception' => $exception->getMessage()] ); + $scan['failed'] = true; break; } if (is_array($found) === false) { + $scan['failed'] = true; break; } + $scan['pages']++; + $scan['rows'] += count($found); foreach ($found as $candidate) { + // The store was asked for expired drafts; the object is asked once more. if ($this->isExpired(candidate: $candidate, cutoff: $cutoff) === true) { - $expired[] = (string)$candidate->getUuid(); + $scan['uuids'][] = (string)$candidate->getUuid(); } } if (count($found) < self::PAGE_SIZE) { - break; + return $scan; } + + $scan['full'] = (($page + 1) === $pages); }//end for - return $expired; - }//end expiredIn() + return $scan; + }//end scan() + + /** + * How many drafts one list matches, for what lies beyond the read cap. + * + * @param Register $register The register. + * @param Schema $schema The schema. + * @param array $query The list query, without paging. + * + * @return int The count; zero when it cannot be asked. + */ + private function countOf(Register $register, Schema $schema, array $query): int { + try { + return (int)$this->objects->countSearchObjects(query: $query, _rbac: false, _multitenancy: false); + } catch (Throwable $exception) { + $this->logger->warning( + message: '[DraftExpiry] The expired drafts beyond the read cap could not be counted', + context: ['register' => $register->getSlug(), 'schema' => $schema->getSlug(), 'exception' => $exception->getMessage()] + ); + + return 0; + } + }//end countOf() /** * Whether a search hit is a draft whose last change is before the cutoff. @@ -351,14 +455,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/lib/Service/Object/DraftOwner.php b/lib/Service/Object/DraftOwner.php index 98f6aaddf3..c18feb5028 100644 --- a/lib/Service/Object/DraftOwner.php +++ b/lib/Service/Object/DraftOwner.php @@ -130,6 +130,24 @@ public static function portalSubject(string $subjectRef): self { return self::scoped(scope: self::PORTAL_SCOPE, reference: $subjectRef); }//end portalSubject() + /** + * The draft's owner: the asserted one, else the signed-in subject, else nobody. + * + * @param IUser|null $subject The signed-in subject. + * @param DraftOwner|null $owner The asserted owner. + * + * @return self|null The owner, or null when there is none. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public static function resolve(?IUser $subject, ?self $owner): ?self { + if ($owner !== null || $subject === null) { + return $owner; + } + + return self::ofUser(user: $subject); + }//end resolve() + /** * The value stored in, and compared with, the object's owner. * diff --git a/lib/Service/Object/DraftStatusPolicy.php b/lib/Service/Object/DraftStatusPolicy.php index c750782408..dfc177e346 100644 --- a/lib/Service/Object/DraftStatusPolicy.php +++ b/lib/Service/Object/DraftStatusPolicy.php @@ -37,12 +37,16 @@ use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Exception\DraftAlreadyLeftException; use OCA\OpenRegister\Exception\ValidationException; use OCA\OpenRegister\Service\Form\FormDestinationValidator; /** * Set, keep, leave and hide drafts. * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One rule set (decisions 180 and 216): every save path and both list + * emitters ask one of these questions, and none of them has a second home to move to. + * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft */ class DraftStatusPolicy { @@ -81,6 +85,23 @@ public static function allowPromotion(string $uuid): void { self::$promotions[$uuid] = true; }//end allowPromotion() + /** + * Whether the save that followed allowPromotion() used the allowance for an object. + * + * A save that leaves draft consumes the allowance. One that did not (the + * stored object had already left draft, because a parallel request won) + * leaves it standing, and the caller must not announce a creation again. + * + * @param string $uuid The object. + * + * @return bool True when no allowance for the object remains. + * + * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft + */ + public static function promotionWasUsed(string $uuid): bool { + return isset(self::$promotions[$uuid]) === false; + }//end promotionWasUsed() + /** * Forget every allowance (tests, and a submit that was refused after allowing). * @@ -100,12 +121,23 @@ public static function resetPromotions(): void { * * @return string|null The status to store; null keeps the date-deduced status. * - * @throws ValidationException When the move is not allowed. + * @throws ValidationException When the move is not allowed, or (DraftAlreadyLeftException) the draft to promote already left draft. * * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft */ public function resolveStatus(ObjectEntity $entity, array $selfData): ?string { $current = $entity->getStatus(); + + // An allowance for an object that is no longer a draft means a parallel + // request sent it first. This save would be an ordinary update that + // announces the creation a second time, so it is refused. + if ($entity->getId() !== null + && $current !== ObjectEntity::STATUS_DRAFT + && isset(self::$promotions[(string)$entity->getUuid()]) === true + ) { + throw new DraftAlreadyLeftException(message: 'This draft has already left draft.'); + } + if (array_key_exists('status', $selfData) === false || $selfData['status'] === null || $selfData['status'] === '') { return $current; } @@ -203,9 +235,9 @@ public function requiredExcusals(Schema $schema): array { /** * The properties the server fills, excused from `required` while a payload is judged in full. * - * A property with a default, a computation or the serverSet marker is - * filled on save or by a creating listener, so a payload without it is - * complete. The form submit and the owner's activation excuse the same + * A property with a default, a computation, a generated identifier + * (`x-openregister-generated`) or the serverSet marker is filled on save or + * by a creating listener, so a payload without it is complete. The form submit and the owner's activation excuse the same * set, so a draft that one accepts the other accepts too. * * @param Schema $schema The schema. @@ -213,7 +245,7 @@ public function requiredExcusals(Schema $schema): array { * * @return array Property => reason, in ValidateObject's notSupplied shape. * - * @SuppressWarnings(PHPMD.StaticAccess) FormDestinationValidator::marker() reads a schema property; there is no instance to inject. + * @SuppressWarnings(PHPMD.StaticAccess) FormDestinationValidator::isServerFilled() reads a schema property; there is no instance to inject. * * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-drafts-owner-must-be-able-to-make-it-definitive-through-the-object-api */ @@ -224,10 +256,7 @@ public function serverFilledExcusals(Schema $schema, array $object): array { continue; } - $filled = array_key_exists('default', $property) === true - || isset($property['computed']) === true - || FormDestinationValidator::marker(property: $property, name: FormDestinationValidator::MARKER_SERVER_SET); - if ($filled === true) { + if (FormDestinationValidator::isServerFilled(property: $property) === true) { $excused[(string)$name] = 'server-set'; } } diff --git a/lib/Service/Object/MergeHandler.php b/lib/Service/Object/MergeHandler.php index 50e7e86994..807a24b85f 100644 --- a/lib/Service/Object/MergeHandler.php +++ b/lib/Service/Object/MergeHandler.php @@ -405,7 +405,7 @@ private function transferObjectFiles(ObjectEntity $sourceObject, ObjectEntity $t $fileName = $file->getName(); // Create new file in target object folder. - $this->fileService->addFile( + $copy = $this->fileService->addFile( objectEntity: $targetObject, fileName: $fileName, content: $fileContent, @@ -413,8 +413,14 @@ private function transferObjectFiles(ObjectEntity $sourceObject, ObjectEntity $t tags: [] ); - // Delete original file from source. - $this->fileService->deleteFile(file: $file, object: $sourceObject); + // Delete original file from source. A source file that is still + // there afterwards (a frozen or archived source: the delete was + // aborted and Nextcloud does not say so) is not a transfer: the + // copy is taken back instead of reporting the file as moved. + if ($this->fileService->deleteFile(file: $file, object: $sourceObject) === false) { + $this->fileService->deleteFile(file: $copy, object: $targetObject); + throw new Exception('The file could not be removed from the source object'); + } $result['files'][] = [ 'name' => $fileName, @@ -472,8 +478,10 @@ private function deleteObjectFiles(ObjectEntity $sourceObject): array { $fileName = $file->getName(); - // Delete the file using FileService. - $this->fileService->deleteFile(file: $file, object: $sourceObject); + // Delete the file using FileService; false means it is still there. + if ($this->fileService->deleteFile(file: $file, object: $sourceObject) === false) { + throw new Exception('The file could not be deleted'); + } $result['files'][] = [ 'name' => $fileName, diff --git a/lib/Service/Object/PermissionHandler.php b/lib/Service/Object/PermissionHandler.php index afff4b12e4..7d4b1ae270 100644 --- a/lib/Service/Object/PermissionHandler.php +++ b/lib/Service/Object/PermissionHandler.php @@ -42,6 +42,7 @@ use OCA\OpenRegister\Event\CustomScopeEvaluatingEvent; use OCA\OpenRegister\Exception\AuthorizationUnresolvableException; use OCA\OpenRegister\Exception\NotAuthorizedException; +use OCA\OpenRegister\Service\AnonymousEvaluationContext; use OCA\OpenRegister\Service\ConditionMatcher; use OCA\OpenRegister\Service\Rbac\DenyEnforcementMode; use OCA\OpenRegister\Service\Rbac\DenyResolver; @@ -441,6 +442,16 @@ public function hasPermission( return true; } + // A draft answers to its owner only (decision 180). The read predicate + // of the list queries keeps it from everybody else, but a check that + // is handed the stored object (bulk save, bulk delete, the readable + // audit trail, a transition, a file) is a second door onto the same + // object and must say the same. Asked before the cache: an object + // that is not a draft costs one comparison. + if ($object !== null && $this->draftIsKeptFromCaller(object: $object, schema: $schema, userId: $userId) === true) { + return false; + } + // Build a per-request cache key. The object UUID (when present) is part // of the key so conditional rules with `match` clauses are still // re-evaluated per object — the cache only deduplicates repeated calls @@ -492,6 +503,60 @@ public function hasPermission( return $verdict; }//end hasPermission() + /** + * Whether the object is a draft that its schema keeps from this caller, whatever the action. + * + * The same answer as the read predicate (MagicRbacHandler): an + * administrator and the command line without a user (occ, cron, repair + * steps) are not kept from a draft, the draft's owner is not, and + * everybody else is, including an anonymous web caller. A schema that sets + * `draftsVisible: true` shows its drafts to anyone who may read the + * schema. Every verb is refused, not only `read`: somebody who cannot + * read a draft cannot change, delete or read the history of it either. + * + * @param ObjectEntity $object The object the check is about. + * @param Schema $schema Its schema. + * @param string|null $userId The caller, null for the session user. + * + * @return bool True when the caller is refused every action on this object. + * + * @SuppressWarnings(PHPMD.StaticAccess) AnonymousEvaluationContext is an ambient-context marker by design. + * + * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-draft-must-not-be-changed-by-somebody-who-cannot-read-it + */ + private function draftIsKeptFromCaller(ObjectEntity $object, Schema $schema, ?string $userId): bool { + if ($object->isDraft() === false) { + return false; + } + + $subject = ($userId ?? $this->userSession->getUser()?->getUID()); + if ((new DraftStatusPolicy())->keptFrom(object: $object, schema: $schema, userId: $subject) === false) { + return false; + } + + if ($subject === null) { + // Nobody is signed in: the command line is the system and passes, + // as it does on the read path; an anonymous web caller is kept. + return AnonymousEvaluationContext::isActive() === true || $this->isCommandLine() === false; + } + + // An administrator reads every draft, so is not kept from one. + $user = $this->userManager->get($subject); + + return $user === null || in_array('admin', $this->groupManager->getUserGroupIds($user), true) === false; + }//end draftIsKeptFromCaller() + + /** + * Whether PHP runs on the command line. A seam, so a test can read as a web request. + * + * @return bool True under the CLI SAPI. + * + * @spec openspec/specs/rbac-scopes/spec.md + */ + protected function isCommandLine(): bool { + return PHP_SAPI === 'cli'; + }//end isCommandLine() + /** * Dispatch the per-action telemetry event. * @@ -1829,6 +1894,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 @@ -1862,6 +1932,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 ($callerIsAdmin === false && (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/Object/ReferenceKey.php b/lib/Service/Object/ReferenceKey.php new file mode 100644 index 0000000000..7ed918be0a --- /dev/null +++ b/lib/Service/Object/ReferenceKey.php @@ -0,0 +1,58 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://www.OpenRegister.app + * + * @spec openspec/specs/referential-integrity/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Object; + +/** + * One reading of `referenceKey`, for the validator that accepts a schema and the service that acts on it. + * + * An empty `referenceKey` and the literal `uuid` both mean a plain uuid + * reference, to which every `onDelete` action applies. A schema save and the + * delete that later honours it read the same declaration, and two readings of + * it (the validator refusing `referenceKey: uuid` with `CASCADE`, the service + * honouring it) is a schema that is refused for something that would have + * worked, or accepted for something that does not. + * + * @spec openspec/specs/referential-integrity/spec.md + */ +final class ReferenceKey { + + /** + * The target's key property, or null for a uuid reference. + * + * Read from the property, or from its `items` for an array of references. + * + * @param array $property The property configuration array. + * + * @return string|null The target's key property, or null for a uuid reference. + * + * @spec openspec/specs/referential-integrity/spec.md + */ + public function keyOf(array $property): ?string { + $key = ($property['referenceKey'] ?? ($property['items']['referenceKey'] ?? null)); + if (is_string($key) === false || trim($key) === '' || trim($key) === 'uuid') { + return null; + } + + return trim($key); + }//end keyOf() +}//end class diff --git a/lib/Service/Object/ReferentialIntegrityService.php b/lib/Service/Object/ReferentialIntegrityService.php index 40d50434aa..e12375c0fa 100644 --- a/lib/Service/Object/ReferentialIntegrityService.php +++ b/lib/Service/Object/ReferentialIntegrityService.php @@ -44,6 +44,7 @@ use OCP\ICacheFactory; use OCP\IDBConnection; use Psr\Log\LoggerInterface; +use RuntimeException; use Symfony\Component\Uid\Uuid; /** @@ -827,12 +828,7 @@ private function extractTargetRef(array $property): ?string { * @spec openspec/specs/referential-integrity/spec.md */ private function extractReferenceKey(array $property): ?string { - $key = ($property['referenceKey'] ?? ($property['items']['referenceKey'] ?? null)); - if (is_string($key) === false || trim($key) === '' || trim($key) === 'uuid') { - return null; - } - - return trim($key); + return (new ReferenceKey())->keyOf(property: $property); }//end extractReferenceKey() /** @@ -1323,17 +1319,25 @@ private function findReferencingInMagicTable( string $targetUuid, bool $isArray, ): array { - $fullTableName = 'oc_openregister_table_' . $register->getId() . '_' . $schema->getId(); - - // Convert camelCase property name to snake_case column name and quote it. - $columnName = strtolower(preg_replace('/[A-Z]/', '_$0', $propertyName)); - $quotedCol = '"' . str_replace('"', '""', $columnName) . '"'; - // Validate inputs before database access (also ensures Psalm sees param usage before \OC:: call). if (empty($targetUuid) === true) { return []; } + $platform = $this->db->getDatabasePlatform(); + + // The table with the instance's prefix, and the column the way the table + // was built (the schema's column map, collision suffixes included), both + // quoted by the connection's own platform: a double-quoted identifier is a + // string literal to MySQL and MariaDB (no ANSI_QUOTES), so `"code" = ?` + // compared the text 'code' with the value, matched nothing and let the + // delete through. Null-safe via get_debug_type() (a mocked IDBConnection + // returns a bare platform double in unit tests). + $quotedTable = $platform->quoteIdentifier( + $this->db->getQueryBuilder()->getTableName($this->objectEntityMapper->getTableNameForRegisterSchema(register: $register, schema: $schema)) + ); + $quotedCol = $platform->quoteIdentifier($this->columnOf(schema: $schema, propertyName: $propertyName)); + // Build the array/scalar SQL variant selector before accessing the database. // For array properties we need JSON_CONTAINS / jsonb @> operators; for scalars a simple = suffices. $queryMode = 'scalar'; @@ -1341,8 +1345,6 @@ private function findReferencingInMagicTable( $queryMode = 'array'; } - $platform = $this->db->getDatabasePlatform(); - // Null-safe via get_debug_type() (a mocked IDBConnection returns null here in unit tests). $isPostgres = stripos(get_debug_type($platform), 'PostgreSQL') !== false; $deletedCheck = '_deleted IS NULL'; @@ -1350,7 +1352,7 @@ private function findReferencingInMagicTable( $deletedCheck = "(_deleted IS NULL OR _deleted = 'null'::jsonb)"; } - $selectClause = "SELECT _uuid, _register, _schema, _deleted, {$quotedCol} AS _prop FROM {$fullTableName}"; + $selectClause = "SELECT _uuid, _register, _schema, _deleted, {$quotedCol} AS _prop FROM {$quotedTable}"; $whereCondition = "{$deletedCheck} AND {$quotedCol} = ?"; if ($queryMode === 'array' && $isPostgres === true) { @@ -1398,6 +1400,31 @@ private function findReferencingInMagicTable( return $results; }//end findReferencingInMagicTable() + /** + * The physical column of a schema property, as the schema's table was built. + * + * Asked of the mapper's own column map rather than derived here, so a + * property whose column carries a collision suffix, or a name the local + * camel-to-snake rule would spell differently, is found where it lives. + * + * @param Schema $schema The citing schema. + * @param string $propertyName The citing property. + * + * @return string The column name. + * + * @throws RuntimeException When the schema's table has no column for the property. + * + * @spec openspec/specs/referential-integrity/spec.md + */ + private function columnOf(Schema $schema, string $propertyName): string { + $column = ($this->objectEntityMapper->buildTableColumnsFromSchema(schema: $schema)[$propertyName]['name'] ?? null); + if (is_string($column) === false || $column === '') { + throw new RuntimeException('The table of schema ' . $schema->getId() . ' has no column for property ' . $propertyName); + } + + return $column; + }//end columnOf() + /** * Check whether a property is required by the given schema. * diff --git a/lib/Service/Object/SaveObject.php b/lib/Service/Object/SaveObject.php index 10e286a4d6..bab7686261 100644 --- a/lib/Service/Object/SaveObject.php +++ b/lib/Service/Object/SaveObject.php @@ -3332,7 +3332,7 @@ public function saveObject( register: $register, schema: $schema, data: $data, - selfData: $selfData, + selfData: $this->selfDataForUpdate(selfData: $selfData, _rbac: $_rbac), folderId: $folderId, persist: $persist, silent: $silent, @@ -3724,6 +3724,36 @@ private function findAndValidateExistingObject( }//end try }//end findAndValidateExistingObject() + /** + * The `@self` an update works from: a version the caller carries counts only for a trusted caller. + * + * An app's seed objects carry their shipped version in `@self.version`, and + * an update takes it when it is higher than the stored one, so the next + * import reads the seed as current (ObjectVersionHandler::advanceVersion()). + * A caller judged by RBAC could send any higher version through the object + * API, and a seed that reads as newer than every later shipped version + * never receives the app's fix. So `@self.version` is dropped from the + * update of a caller whose RBAC applies, and that update bumps the patch + * like any other. The importer, an administrator and a system caller save + * with RBAC off and keep it. + * + * @param array $selfData The `@self` the caller sent. + * @param bool $_rbac Whether the save is judged for a signed-in caller. + * + * @return array The `@self` the update uses. + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) Mirrors saveObject()'s own RBAC flag. + * + * @spec openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md + */ + private function selfDataForUpdate(array $selfData, bool $_rbac): array { + if ($_rbac === true) { + unset($selfData['version']); + } + + return $selfData; + }//end selfDataForUpdate() + /** * Handle update of existing object. * diff --git a/lib/Service/Object/SaveObjects.php b/lib/Service/Object/SaveObjects.php index f2c8d77968..b66c3acb98 100644 --- a/lib/Service/Object/SaveObjects.php +++ b/lib/Service/Object/SaveObjects.php @@ -802,6 +802,31 @@ private function applyBulkSafeguards( existingByUuid: $existingByUuid ); + // 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 + // unannounced (same rule as ObjectService::refuseSomebodyElsesDraft). + // 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, + 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 4: appendOnly UPDATE → reject. if ($action === 'update' && $rowSchema->isAppendOnly() === true) { $this->recordSafeguardRejection( diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 59780d6005..f6b5e9c94a 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. + $deleter = ($currentUser ?? $this->userSession->getUser()); + if ($_rbac === true && $objectToDelete !== null && $this->currentSchema !== null + && $this->draftKeptFromCaller(object: $objectToDelete, schema: $this->currentSchema, user: $deleter) === true + ) { + throw new OcpDoesNotExistException('Object not found'); + } + return $this->deleteHandler->deleteObject( register: $this->currentRegister, schema: $this->currentSchema, @@ -3340,6 +3351,30 @@ private function isDeletableDraft(string $uuid, bool $retentionSweep, bool $_rba } }//end isDeletableDraft() + /** + * Whether a stored draft is kept from the caller, as on the read path. + * + * One answer for the single delete and the bulk delete: another user's + * draft is not theirs to remove. An administrator reads every draft, so + * is not kept from one. + * + * @param ObjectEntity $object The stored object. + * @param Schema $schema Its schema. + * @param IUser|null $user The caller. + * + * @return bool True when the object is a draft the caller may not touch. + * + * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-draft-must-not-be-changed-by-somebody-who-cannot-read-it + */ + private function draftKeptFromCaller(ObjectEntity $object, Schema $schema, ?IUser $user): bool + { + if ($user !== null && in_array('admin', $this->groupManager->getUserGroupIds($user), true) === true) { + return false; + } + + return (new DraftStatusPolicy())->keptFrom(object: $object, schema: $schema, userId: $user?->getUID()); + }//end draftKeptFromCaller() + /** * Refuse a delete on an archival-annotated schema. * @@ -5557,10 +5592,12 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten // object still yields its scope. $deletedRegisterId = null; $deletedSchemaId = null; + $rowEntity = null; $preResolved = ($preResolvedObjects[$uuid] ?? null); if ($preResolved !== null) { $deletedRegisterId = $preResolved->getRegister(); $deletedSchemaId = $preResolved->getSchema(); + $rowEntity = $preResolved; } if ($preResolved === null) { @@ -5575,6 +5612,7 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten ); $deletedRegisterId = $preDeleteObject->getRegister(); $deletedSchemaId = $preDeleteObject->getSchema(); + $rowEntity = $preDeleteObject; } catch (\Throwable $resolveError) { // BUG-OBJ-14: scope resolution failed (object already gone or // not visible) — log and fall back to a broad invalidation. @@ -5635,14 +5673,35 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten continue; } - try { - $this->rejectIfArchivalImmutable(schema: $gateSchema, retentionSweep: false); - } catch (ArchivalImmutableException $archivalRefusal) { + // THE DRAFT RULES, ON THE THIRD DOOR TOO (decisions 180 and 216). + // The permission filter above already leaves another user's draft + // out; this asks again per row, with the row's own schema, so the + // loop does not lean on that one gate. A draft is then removed for + // good, with its files and its one `draft.removed` audit entry, and + // it is not an archival record, so the archival gate does not + // apply to it: both exactly as deleteObject() has them. + $rowIsDraft = ($rowEntity !== null && $rowEntity->isDraft() === true); + if ($rowIsDraft === true && $_rbac === true + && $this->draftKeptFromCaller(object: $rowEntity, schema: $gateSchema, user: $this->userSession->getUser()) === true + ) { $skippedUuids[] = $uuid; - $skippedReasons[$uuid] = $archivalRefusal->toResponseBody(); + $skippedReasons[$uuid] = [ + 'error' => 'OBJECT_NOT_PERMITTED', + 'message' => 'Object is not visible to this user, or does not exist.', + ]; continue; } + if ($rowIsDraft === false) { + try { + $this->rejectIfArchivalImmutable(schema: $gateSchema, retentionSweep: false); + } catch (ArchivalImmutableException $archivalRefusal) { + $skippedUuids[] = $uuid; + $skippedReasons[$uuid] = $archivalRefusal->toResponseBody(); + continue; + } + } + $result = $this->deleteHandler->deleteObject( register: $handlerRegister, schema: $handlerSchema, @@ -5650,7 +5709,8 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten originalObjectId: null, _rbac: $_rbac, _multitenancy: $_multitenancy, - preResolved: $handlerPreResolved + preResolved: $handlerPreResolved, + permanent: $rowIsDraft ); if ($result === true) { $deletedObjectIds[] = $uuid; diff --git a/lib/Service/Portal/PortalTaskService.php b/lib/Service/Portal/PortalTaskService.php index efd78d62b4..b5c337aaa4 100644 --- a/lib/Service/Portal/PortalTaskService.php +++ b/lib/Service/Portal/PortalTaskService.php @@ -43,6 +43,7 @@ use OCA\OpenRegister\Exception\TaskCompleteViaAppException; use OCA\OpenRegister\Exception\TaskValidationException; use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\Task\TaskBlockerGuard; use OCA\OpenRegister\Service\Task\TaskInboxService; use OCA\OpenRegister\Service\Task\TaskService; use OCA\OpenRegister\Service\Task\TaskTemporalProjection; @@ -126,6 +127,10 @@ public function listForSubject(PortalSubject $subject, int $limit = 25, int $off $contexts = $this->inbox->subjectContextsFor(tasks: $page); $now = $this->temporal->now(); + // The page was read with the open-blocker exclusion, so no row of it is + // blocked: saying so up front spares every row its own blocker query. + $openBlockers = []; + $results = []; foreach ($page as $task) { // Belt and braces over the WHERE clause: a row that is not this @@ -134,7 +139,7 @@ public function listForSubject(PortalSubject $subject, int $limit = 25, int $off continue; } - $results[] = $this->inbox->row(task: $task, subjects: $contexts, now: $now); + $results[] = $this->inbox->row(task: $task, subjects: $contexts, now: $now, openBlockers: $openBlockers); } return [ @@ -153,9 +158,11 @@ public function listForSubject(PortalSubject $subject, int $limit = 25, int $off * * @return Task The task. * - * @throws DoesNotExistException When absent, not external, or not this - * subject's: all three read identically, so - * a stranger learns nothing from the answer. + * @throws DoesNotExistException When absent, not external, not this + * subject's, or held back by an open blocker + * (the list leaves it out too): all read + * identically, so a stranger learns nothing + * from the answer. * * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else */ @@ -163,6 +170,7 @@ public function show(PortalSubject $subject, string $uuid): Task { $task = $this->tasks->get(uuid: $uuid); if ((string)$task->getPerformerType() !== Task::PERFORMER_EXTERNAL || hash_equals($subject->partyReference(), (string)$task->getAssignee()) === false + || (new TaskBlockerGuard(tasks: $this->mapper))->isHeldBack(task: $task) === true ) { throw new DoesNotExistException('No such portal task.'); } @@ -170,6 +178,7 @@ public function show(PortalSubject $subject, string $uuid): Task { return $task; }//end show() + /** * The row shape of one task for the portal, with delivery state and * case context. @@ -211,7 +220,7 @@ public function row(Task $task): array { * @throws TaskCompleteViaAppException When the task names an app action that * completes it and the caller is not that app (nothing stored). * @throws TaskValidationException When an upload constraint is violated (nothing stored). - * @throws DoesNotExistException When no such task exists. + * @throws DoesNotExistException When no such task exists, or it waits on an open blocker. * * @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-an-upload-completion-lands-as-a-file-attachment-on-the-case-object */ @@ -229,6 +238,12 @@ public function complete( // file on somebody else's case. $task = $this->tasks->openFor(verb: 'complete', uuid: $uuid, actor: $subject->actor()); + // 1a. A task the case holds back behind an open blocker is not on the + // party's list, so it is not theirs to complete either. Asked after + // the authorization, so only the matched party can tell it from a + // task that does not exist, and before anything is stored. + (new TaskBlockerGuard(tasks: $this->mapper))->assertNotHeldBack(task: $task); + // 1b. A task that names the app action completing it is completed by // that app alone, so the app's own checks cannot be skipped by // calling this seam directly. Only the matched party gets here, so 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/lib/Service/Rbac/EffectiveAuthorization.php b/lib/Service/Rbac/EffectiveAuthorization.php index aae3df203c..c9f4073c23 100644 --- a/lib/Service/Rbac/EffectiveAuthorization.php +++ b/lib/Service/Rbac/EffectiveAuthorization.php @@ -117,18 +117,18 @@ private function expandRoles(array $authorization, object $schema, ?object $regi $register = $this->registerMapper->find($registerId); } } catch (\Throwable $e) { - return $authorization; + return $this->grantNothingWhenEmpty(authorization: $authorization); } } if ($register === null) { - return $authorization; + return $this->grantNothingWhenEmpty(authorization: $authorization); } $config = $register->getConfiguration(); $roles = $config['roles'] ?? []; if (empty($roles) === true) { - return $authorization; + return $this->grantNothingWhenEmpty(authorization: $authorization); } // Build role map. @@ -158,7 +158,36 @@ private function expandRoles(array $authorization, object $schema, ?object $regi } } - return $authorization; + return $this->grantNothingWhenEmpty(authorization: $authorization); }//end expandRoles() + /** + * A roles block that resolved to nothing grants nothing. + * + * The same rule as `PermissionHandler::grantNothingWhenEmpty()`, which is what + * enforcement applies: a block that opted into authorization with `roles` and + * whose roles cannot be expanded (no role definitions, or every name unknown) + * is deny-all, not "no authorization configured". The OAS must describe the + * schema the way it is enforced, so it reads the same four canonical verbs + * with nobody in them instead of an empty block. + * + * @param array $authorization The block after expansion. + * + * @return array The block, or one granting nobody when it is empty. + * + * @spec openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md + */ + private function grantNothingWhenEmpty(array $authorization): array { + if ($authorization !== []) { + return $authorization; + } + + return [ + 'read' => [], + 'create' => [], + 'update' => [], + 'delete' => [], + ]; + }//end grantNothingWhenEmpty() + }//end class diff --git a/lib/Service/Rbac/GrantChangeAuditor.php b/lib/Service/Rbac/GrantChangeAuditor.php new file mode 100644 index 0000000000..d0057a15ca --- /dev/null +++ b/lib/Service/Rbac/GrantChangeAuditor.php @@ -0,0 +1,202 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Rbac; + +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Turns a change of who may reach a record into audit-trail entries. + * + * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md + */ +class GrantChangeAuditor { + + /** + * The typed action a grant on a record writes. + * + * @var string + */ + public const ACTION_GRANTED = 'grant_added'; + + /** + * The typed action a revoked grant writes. + * + * @var string + */ + public const ACTION_REVOKED = 'grant_revoked'; + + /** + * The typed action a change of a record's owning group writes. + * + * @var string + */ + public const ACTION_OWNER_GROUP = 'owner_group_changed'; + + /** + * Constructor. + * + * @param AuditTrailMapper $mapper The trail, which owns the chain. + * @param SettingsChangeAuditor $settings Writes the change of an organisation's special right list. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + private readonly AuditTrailMapper $mapper, + private readonly SettingsChangeAuditor $settings, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Record a grant on a record. + * + * @param ObjectEntity $object The record. + * @param array $grant The grant as created: `id`, `type`, `sharedWith`, `permissions`, `verbs`. + * @param string|null $admittedAs How the caller was admitted (GrantManageGuard::AS_OWNER or ::AS_HOLDER). + * + * @return void + * + * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md + */ + public function recordGrant(ObjectEntity $object, array $grant, ?string $admittedAs): void { + $this->write(object: $object, action: self::ACTION_GRANTED, changed: $this->describe(grant: $grant, admittedAs: $admittedAs)); + }//end recordGrant() + + /** + * Record a revoked grant on a record. + * + * @param ObjectEntity $object The record. + * @param array $grant The grant as it was: `id`, `type`, `sharedWith`, `permissions`. + * @param string|null $admittedAs How the caller was admitted. + * + * @return void + * + * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md + */ + public function recordRevoke(ObjectEntity $object, array $grant, ?string $admittedAs): void { + $this->write(object: $object, action: self::ACTION_REVOKED, changed: $this->describe(grant: $grant, admittedAs: $admittedAs)); + }//end recordRevoke() + + /** + * Record a change of a record's owning group. + * + * @param ObjectEntity $object The record. + * @param string|null $before The owning group before, or null for none. + * @param string|null $after The owning group after, or null for none. + * + * @return void + * + * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md + */ + public function recordOwnerGroup(ObjectEntity $object, ?string $before, ?string $after): void { + if ($before === $after) { + return; + } + + $this->write( + object: $object, + action: self::ACTION_OWNER_GROUP, + changed: ['ownerGroup' => ['old' => $before, 'new' => $after]] + ); + }//end recordOwnerGroup() + + /** + * Record a change of the groups that hold the special right on an organisation's records. + * + * @param string $organisationUuid The organisation. + * @param array $before The list before. + * @param array $after The list after. + * + * @return void + * + * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md + */ + public function recordSpecialRight(string $organisationUuid, array $before, array $after): void { + $key = 'organisation ' . $organisationUuid . ': ' . OrganisationSpecialRights::GRANT_MANAGE; + + $this->settings->recordUpdate(app: 'openregister', before: [$key => $before], after: [$key => $after]); + }//end recordSpecialRight() + + /** + * What of a grant the entry names. + * + * @param array $grant The grant. + * @param string|null $admittedAs How the caller was admitted. + * + * @return array The change set of the entry. + */ + private function describe(array $grant, ?string $admittedAs): array { + return [ + 'grant' => $grant['id'] ?? null, + 'type' => $grant['type'] ?? null, + 'sharedWith' => $grant['sharedWith'] ?? null, + 'permissions' => $grant['permissions'] ?? null, + 'verbs' => ($grant['verbs'] ?? []), + 'admittedAs' => $admittedAs, + ]; + }//end describe() + + /** + * Write one entry; never throws. + * + * @param ObjectEntity $object The record. + * @param string $action The typed action. + * @param array $changed The change set. + * + * @return void + */ + private function write(ObjectEntity $object, string $action, array $changed): void { + try { + $this->mapper->createAuditTrailEntry(object: $object, action: $action, context: $changed); + } catch (Throwable $e) { + $this->logger->error( + message: '[GrantChangeAuditor] Could not record a change of who may reach a record; the change itself was stored', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'action' => $action, + 'uuid' => $object->getUuid(), + 'exception' => $e->getMessage(), + ] + ); + } + }//end write() +}//end class 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/HierarchyDescender.php b/lib/Service/Rbac/HierarchyDescender.php index a092684e27..9a3971974b 100644 --- a/lib/Service/Rbac/HierarchyDescender.php +++ b/lib/Service/Rbac/HierarchyDescender.php @@ -51,6 +51,11 @@ /** * Finds the hierarchical schemas, and reads one level of children at a time. * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One class answers the + * declaration, the descent and the ascent of a hierarchy over the same memoised + * tables, and each question is a short method; splitting them would put the + * memo in two places, which is how a stale answer reaches an authorization verdict. + * * @spec openspec/changes/rbac-inherits-to-children/specs/rbac-scopes/spec.md */ class HierarchyDescender { @@ -195,51 +200,92 @@ public function hierarchicalTables(): array { /** * The children of a set of parents, as child uuid => parent uuid. * + * With a `$limit` the read itself is bounded: at most that many rows leave + * the database, so a caller that stops at a node cap does not first fetch + * a level of a hundred thousand rows to find out it is over it. A row + * naming itself as its parent is then left out in SQL, so it does not use + * up the limit. + * * @param string $table The magic table. * @param string $parentColumn The column naming the parent. * @param string[] $parentUuids The parents to read children of. + * @param int|null $limit The most rows to read, or null for the whole level. * * @return array Child UUID => parent UUID. * * @spec openspec/changes/rbac-inherits-to-children/specs/rbac-scopes/spec.md */ - public function childrenOf(string $table, string $parentColumn, array $parentUuids): array { - if (empty($parentUuids) === true) { + public function childrenOf(string $table, string $parentColumn, array $parentUuids, ?int $limit = null): array { + if (empty($parentUuids) === true || ($limit !== null && $limit < 1)) { return []; } $children = []; foreach (array_chunk($parentUuids, self::CHUNK) as $chunk) { - $qb = $this->db->getQueryBuilder(); - $qb->select('_uuid', $parentColumn) - ->from($table) - ->where( - $qb->expr()->in( - $parentColumn, - $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY) - ) - ); + $remaining = null; + if ($limit !== null) { + $remaining = ($limit - count($children)); + } - $result = $qb->executeQuery(); - foreach ($result->fetchAll() as $row) { - $childUuid = (string)($row['_uuid'] ?? ''); - $parentUuid = (string)($row[$parentColumn] ?? ''); - if ($childUuid === '' || $parentUuid === '' || $childUuid === $parentUuid) { - // A row naming ITSELF as its parent is the shortest cycle - // there is, and it is the one an import writes. Dropping it - // here means the expander never has to treat it specially. - continue; - } + $children = array_replace( + $children, + $this->childrenInChunk(table: $table, parentColumn: $parentColumn, parents: $chunk, limit: $remaining) + ); - $children[$childUuid] = $parentUuid; + if ($limit !== null && count($children) >= $limit) { + break; } - - $result->closeCursor(); - }//end foreach + } return $children; }//end childrenOf() + /** + * The children of one chunk of parents, as child uuid => parent uuid. + * + * @param string $table The magic table. + * @param string $parentColumn The column naming the parent. + * @param string[] $parents The parents of this chunk. + * @param int|null $limit The most rows to read, or null for all of them. + * + * @return array Child UUID => parent UUID. + */ + private function childrenInChunk(string $table, string $parentColumn, array $parents, ?int $limit): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('_uuid', $parentColumn) + ->from($table) + ->where( + $qb->expr()->in( + $parentColumn, + $qb->createNamedParameter($parents, IQueryBuilder::PARAM_STR_ARRAY) + ) + ); + + if ($limit !== null) { + $qb->andWhere($qb->expr()->neq('_uuid', $parentColumn)); + $qb->setMaxResults($limit); + } + + $children = []; + $result = $qb->executeQuery(); + foreach ($result->fetchAll() as $row) { + $childUuid = (string)($row['_uuid'] ?? ''); + $parentUuid = (string)($row[$parentColumn] ?? ''); + if ($childUuid === '' || $parentUuid === '' || $childUuid === $parentUuid) { + // A row naming ITSELF as its parent is the shortest cycle + // there is, and it is the one an import writes. Dropping it + // here means the expander never has to treat it specially. + continue; + } + + $children[$childUuid] = $parentUuid; + } + + $result->closeCursor(); + + return $children; + }//end childrenInChunk() + /** * The ancestors of one object, nearest first. * diff --git a/lib/Service/Rbac/ObjectOwnershipService.php b/lib/Service/Rbac/ObjectOwnershipService.php index c4f4a02971..cb12c7d8cd 100644 --- a/lib/Service/Rbac/ObjectOwnershipService.php +++ b/lib/Service/Rbac/ObjectOwnershipService.php @@ -137,6 +137,7 @@ class ObjectOwnershipService { * @param SchemaMapper $schemaMapper Resolves a child's schema. * @param INotificationManager $notificationManager Tells the previous owner. * @param LoggerInterface $logger Where a transfer is noted. + * @param GrantChangeAuditor|null $grantAuditor Writes an audit-trail entry for a change of the owning group. Null only in legacy fixtures. * * @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor injection; see the * coupling note on the class. @@ -155,6 +156,7 @@ public function __construct( private readonly SchemaMapper $schemaMapper, private readonly INotificationManager $notificationManager, private readonly LoggerInterface $logger, + private readonly ?GrantChangeAuditor $grantAuditor = null, ) { }//end __construct() @@ -378,6 +380,7 @@ public function setOwnerGroup( // Read-modify-write ONE key, exactly as the scope is written: an action // override or a scope in the same block must survive this. $block = ($object->getAuthorization() ?? []); + $before = $this->groupNameOf(value: ($block[ObjectScopeResolver::OWNER_GROUP_KEY] ?? null)); if ($group === null || $group === '') { unset($block[ObjectScopeResolver::OWNER_GROUP_KEY]); @@ -396,9 +399,30 @@ public function setOwnerGroup( $object->setAuthorization($block); + $this->grantAuditor?->recordOwnerGroup( + object: $object, + before: $before, + after: $this->groupNameOf(value: ($block[ObjectScopeResolver::OWNER_GROUP_KEY] ?? null)) + ); + return $block; }//end setOwnerGroup() + /** + * The group name an authorization block holds, or null for none. + * + * @param mixed $value The stored `ownerGroup` value. + * + * @return string|null The group id. + */ + private function groupNameOf(mixed $value): ?string { + if (is_string($value) === true && trim($value) !== '') { + return trim($value); + } + + return null; + }//end groupNameOf() + /** * Move one record to a new owner, record it, announce it, and carry it down. * diff --git a/lib/Service/Rbac/ObjectSharingService.php b/lib/Service/Rbac/ObjectSharingService.php index 7e3faa59e0..673933f8e3 100644 --- a/lib/Service/Rbac/ObjectSharingService.php +++ b/lib/Service/Rbac/ObjectSharingService.php @@ -138,6 +138,7 @@ class ObjectSharingService { * @param InheritedGrantLister $inheritedGrants Lists the grants an ancestor's share confers. * @param GrantManageGuard $grantManage Who may manage a record's grants: the owner, an administrator, * or a holder of the special right `grant_manage`. + * @param GrantChangeAuditor|null $auditor Writes an audit-trail entry for every grant and revoke. Null only in legacy fixtures. * * @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor injection; each dependency is named in the * class docblock, and the guard is the tenth. @@ -153,6 +154,7 @@ public function __construct( private readonly LoggerInterface $logger, private readonly InheritedGrantLister $inheritedGrants, private readonly GrantManageGuard $grantManage, + private readonly ?GrantChangeAuditor $auditor = null, ) { }//end __construct() @@ -302,7 +304,7 @@ public function grant( int $permissions = 1, array $verbs = [], ): array { - $this->requireMayManageGrants(object: $object, grantType: $type, grantee: trim($shareWith)); + $admittedAs = $this->requireMayManageGrants(object: $object, grantType: $type, grantee: trim($shareWith)); if (isset(self::GRANTABLE_TYPES[$type]) === false) { throw new InvalidArgumentException( @@ -338,13 +340,19 @@ public function grant( // The caller may re-decide access within this same request. $this->grantResolver->forget(); - return [ + $grant = [ 'id' => $created->getFullId(), 'type' => $type, 'sharedWith' => $created->getSharedWith(), 'permissions' => $created->getPermissions(), 'verbs' => array_values($verbs), ]; + + // Who let this principal in, and when, is the question the trail has + // to be able to answer, whoever the caller was admitted as. + $this->auditor?->recordGrant(object: $object, grant: $grant, admittedAs: $admittedAs); + + return $grant; }//end grant() /** @@ -583,7 +591,7 @@ private function applyLinkOptions(IShare $share, ?string $password, ?string $exp * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md */ public function revoke(ObjectEntity $object, string $shareId): void { - $this->requireMayManageGrants(object: $object); + $admittedAs = $this->requireMayManageGrants(object: $object); $folder = $this->resolveFolder(object: $object); if ($folder === null) { @@ -600,10 +608,35 @@ public function revoke(ObjectEntity $object, string $shareId): void { throw new InvalidArgumentException('That grant does not belong to this object'); } + $revoked = [ + 'id' => $share->getFullId(), + 'type' => $this->typeLabel(shareType: (int)$share->getShareType()), + 'sharedWith' => $share->getSharedWith(), + 'permissions' => $share->getPermissions(), + ]; + $this->shareManager->deleteShare($share); $this->grantResolver->forget(); + + $this->auditor?->recordRevoke(object: $object, grant: $revoked, admittedAs: $admittedAs); }//end revoke() + /** + * The label of a core share type, as the grants name it. + * + * @param int $shareType The core share type. + * + * @return string The label (`user`, `group`, `link`, ...), or the number when it has none. + */ + private function typeLabel(int $shareType): string { + $label = array_search($shareType, self::LISTABLE_TYPES, true); + if ($label === false) { + return (string)$shareType; + } + + return (string)$label; + }//end typeLabel() + /** * Resolve the object's NC folder, creating it if it has none. * @@ -678,7 +711,7 @@ private function requireOwnerOrAdmin(ObjectEntity $object): void { * * @throws NotAuthorizedException When the caller may not. * - * @return void + * @return string How the caller was admitted (GrantManageGuard::AS_OWNER or ::AS_HOLDER). * * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md */ @@ -686,7 +719,7 @@ private function requireMayManageGrants( ObjectEntity $object, ?string $grantType = null, ?string $grantee = null, - ): void { + ): string { $uid = $this->callerUid(); $groups = []; if ($uid !== null) { @@ -706,13 +739,16 @@ 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( message: 'The right to manage grants does not let its holder grant access to itself' ); } + + return $admittedAs; }//end requireMayManageGrants() /** diff --git a/lib/Service/RegisterDescriptorService.php b/lib/Service/RegisterDescriptorService.php index 657f31f0b3..d8671ec8b1 100644 --- a/lib/Service/RegisterDescriptorService.php +++ b/lib/Service/RegisterDescriptorService.php @@ -44,6 +44,7 @@ namespace OCA\OpenRegister\Service; use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds; use OCA\OpenRegister\Service\ShippedBaseline\ShippedAuthorizationDecision; use OCP\App\IAppManager; use Psr\Log\LoggerInterface; @@ -223,7 +224,8 @@ public function inventory(): array { * @param string $slug A register slug the configuration declares. * @param string $via Who asked, recorded with every authorization block the decision replaces. * - * @return array outcome, reason, path, via, version, counts{registers, schemas, authorizationReplaced}, replaced. + * @return array outcome, reason, path, via, version, counts{registers, schemas, authorizationReplaced}, replaced, + * and, when the import recorded a job, importJobId and importAppId (the configuration id it ran under). * * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md */ @@ -275,29 +277,61 @@ public function reimport(string $appId, string $slug, string $via = self::VIA_OC }//end reimport() /** - * Import one non-mock descriptor file, forced, under the app's own configuration id. + * Import one non-mock descriptor file, forced, under the configuration id its install-time import uses. + * + * 🔴 THE CONFIGURATION ID IS THE ONE THE REPAIR STEP USES, not always the + * app's. OpenRegister imports eight of its own descriptors under an id of + * their own (see {@see DescriptorConfigIds}): importing one under the + * shared `openregister` id would set that configuration's version to the + * descriptor's, lowering or raising the gate the force:false repair steps + * of flows, surveys, credential-broker, flow-timers and intake-sources + * compare against, and would append the register to a second configuration + * besides its own. * * @param string $appId The app. * @param array{file: string, data: array, registers: array} $descriptor The descriptor. * @param string $version Its version. * - * @return array{success: bool, path: string, via: string, message: string} + * @return array{success: bool, path: string, via: string, message: string, importJobId: string|null, importAppId: string} + * + * @SuppressWarnings(PHPMD.StaticAccess) DescriptorConfigIds::forFile() is a stateless lookup table shared with the + * repair steps; injecting it would add a dependency without removing any coupling. */ private function importDescriptorFile(string $appId, array $descriptor, string $version): array { - $via = sprintf('OpenRegister (lib/Settings/%s)', $descriptor['file']); + $via = sprintf('OpenRegister (lib/Settings/%s)', $descriptor['file']); + $configId = DescriptorConfigIds::forFile(appId: $appId, file: $descriptor['file']); try { - $this->configurationService->importFromApp( - appId: $appId, + $imported = $this->configurationService->importFromApp( + appId: $configId, data: $descriptor['data'], version: $version, force: true ); } catch (Throwable $e) { $this->logger->warning('[RegisterDescriptorService] re-import failed: ' . $e->getMessage()); - return ['success' => false, 'path' => self::PATH_DESCRIPTOR, 'via' => $via, 'message' => $e->getMessage()]; + return [ + 'success' => false, + 'path' => self::PATH_DESCRIPTOR, + 'via' => $via, + 'message' => $e->getMessage(), + 'importJobId' => null, + 'importAppId' => $configId, + ]; + } + + $jobId = ($imported['importJobId'] ?? null); + if (is_string($jobId) === false || $jobId === '') { + $jobId = null; } - return ['success' => true, 'path' => self::PATH_DESCRIPTOR, 'via' => $via, 'message' => '']; + return [ + 'success' => true, + 'path' => self::PATH_DESCRIPTOR, + 'via' => $via, + 'message' => '', + 'importJobId' => $jobId, + 'importAppId' => $configId, + ]; }//end importDescriptorFile() /** @@ -347,7 +381,9 @@ private function declaredSlugs(array $document): array { * `counts` reports what actually landed, so `imported` cannot be said over a * no-op. * - * @return array{outcome: string, reason: string|null, counts?: array{declared: int, imported: int, skipped: int}} + * @return array `outcome` and `reason`; `counts` ({declared, imported, skipped}) once the import ran, + * with `importJobId` and `importAppId`: the job it recorded and the configuration + * id it ran under. * * @spec openspec/changes/register-descriptor-admin/specs/register-descriptor-admin/spec.md */ @@ -397,12 +433,15 @@ public function reimportMock(string $appId, string $slug): array { return ['outcome' => 'failed', 'reason' => $e->getMessage()]; } + // The job the import ran under, so a caller that undoes the import (an example set's remove) claims exactly it. + $job = ['importJobId' => ($result['importJobId'] ?? null), 'importAppId' => $configId]; + $after = $this->installedRegisterVersions()[strtolower($slug)] ?? null; if ($after === null) { return [ 'outcome' => 'failed', 'reason' => sprintf('The import reported no error but register "%s" still does not exist.', $slug), - ]; + ] + $job; } // 🔴 COUNT WHAT LANDED, and fail when a descriptor that declares objects @@ -426,14 +465,14 @@ public function reimportMock(string $appId, string $slug): array { $skipped ), 'counts' => ['declared' => $declaredObjects, 'imported' => 0, 'skipped' => $skipped], - ]; + ] + $job; } return [ 'outcome' => 'imported', 'reason' => null, 'counts' => ['declared' => $declaredObjects, 'imported' => $imported, 'skipped' => $skipped], - ]; + ] + $job; }//end reimportMock() /** diff --git a/lib/Service/Relation/ObjectTreeService.php b/lib/Service/Relation/ObjectTreeService.php index fd2d6fb5d4..678f8439be 100644 --- a/lib/Service/Relation/ObjectTreeService.php +++ b/lib/Service/Relation/ObjectTreeService.php @@ -38,6 +38,7 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Object\RenderObject; use OCA\OpenRegister\Service\Party\PartyDefinition; use OCA\OpenRegister\Service\Rbac\HierarchyDescender; use OCA\OpenRegister\Service\Rbac\HierarchyGrantExpander; @@ -49,6 +50,9 @@ * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The walk reads the declaration, * the table, the access filter and the instance cap; each is its own class. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Two directions, three bounds and a + * fail-closed answer for each unreadable level or ancestor; each is a short method, and + * splitting them would scatter one walk's bounds over several classes. * * @spec openspec/changes/object-tree-read/specs/referential-integrity/spec.md */ @@ -105,6 +109,7 @@ class ObjectTreeService { * @param RegisterMapper $registerMapper Resolves the root's register. * @param SchemaMapper $schemaMapper Resolves the root's schema. * @param RelationGraphService $graphs Holds the instance's node cap. + * @param RenderObject $renderer Strips what the caller may not read from the properties a node carries. * @param LoggerInterface $logger The logger. */ public function __construct( @@ -114,6 +119,7 @@ public function __construct( private readonly RegisterMapper $registerMapper, private readonly SchemaMapper $schemaMapper, private readonly RelationGraphService $graphs, + private readonly RenderObject $renderer, private readonly LoggerInterface $logger, ) { }//end __construct() @@ -165,7 +171,7 @@ public function declarationFor(Schema $schema): ?array { * @param ObjectEntity $root The object to walk from, already read under the caller's RBAC. * @param string $direction One of the DIRECTION_ constants. * @param int|null $depth The depth asked for; null means the declared bound. - * @param string[] $fields Object properties to copy onto each node. + * @param string[] $fields Object properties to copy onto each node, after the read-side redaction. * @param bool $_rbac Whether to apply the caller's read access to every node. * * @return array{root: string, register: int, schema: int, parentProperty: string|null, @@ -197,7 +203,15 @@ public function tree( 'depth' => 0, 'requestedDepth' => $depth, 'maxDepth' => 0, - 'nodes' => [$this->node(object: $root, level: 0, parent: null, fields: $fields)], + 'nodes' => [ + $this->node( + object: ($this->redacted(objects: [(string)$root->getUuid() => $root], fields: $fields, rbac: $_rbac)[$rootUuid] ?? null), + level: 0, + parent: null, + fields: $fields, + uuid: $rootUuid + ), + ], 'truncated' => false, 'truncatedBy' => null, ]; @@ -290,34 +304,48 @@ private function descendants(string $rootUuid, array $walk): array { $frontier = [$rootUuid]; for ($level = 1; $level <= $walk['depth']; $level++) { - $children = $this->readableChildren(table: $table, column: $column, parents: $frontier, seen: $seen, walk: $walk); - if ($children === null) { + // What the cap still lets in, the root counted: the read below is + // bounded by it, so a level far wider than the cap is never fetched. + $budget = ($cap - count($nodes) - 1); + $read = $this->readableChildren( + table: $table, + column: $column, + read: ['parents' => $frontier, 'budget' => $budget, 'seen' => $seen, 'walk' => $walk] + ); + if ($read === null) { return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_UNREADABLE]; } - if ($children === []) { - return ['nodes' => $nodes, 'truncatedBy' => null]; + if ($read['overflow'] === true) { + return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_CAP]; } - if ((count($nodes) + count($children) + 1) > $cap) { - return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_CAP]; + $children = $read['children']; + if ($children === []) { + return ['nodes' => $nodes, 'truncatedBy' => null]; } - $objects = $this->load(walk: $walk, uuids: array_keys($children)); + $objects = $this->redacted(objects: $this->load(walk: $walk, uuids: array_keys($children)), fields: $walk['fields'], rbac: $walk['rbac']); $frontier = []; foreach ($children as $child => $parent) { $seen[$child] = true; $frontier[] = $child; $nodes[] = $this->node(object: ($objects[$child] ?? null), level: $level, parent: $parent, fields: $walk['fields'], uuid: $child); } - } + }//end for - $more = $this->readableChildren(table: $table, column: $column, parents: $frontier, seen: $seen, walk: $walk); - if ($more === null) { + $read = $this->readableChildren( + table: $table, + column: $column, + read: ['parents' => $frontier, 'budget' => ($cap - count($nodes) - 1), 'seen' => $seen, 'walk' => $walk] + ); + if ($read === null) { return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_UNREADABLE]; } - if ($more !== []) { + // An overflowing probe still answers from the rows it did read: one + // readable child among them is enough to say the depth stopped the walk. + if ($read['children'] !== []) { return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_DEPTH]; } @@ -327,23 +355,35 @@ private function descendants(string $rootUuid, array $walk): array { /** * The children of a level the caller may read and has not met yet, or null when the level cannot be read. * - * A cycle in the data shows up here as a child already seen, and is dropped. + * The read is bounded by the budget: one row more than the cap still lets + * in is asked for, and getting it means the level is wider than the cap + * (`overflow`); the access check then runs on the rows that fit only, so + * the work stays bounded too. A cycle in the data shows up here as a child + * already seen, and is dropped. Null covers both a level that could not be + * read and one whose read access could not be established: neither may be + * answered as "nothing below". * - * @param string $table The magic table. - * @param string $column The parent column. - * @param string[] $parents The level to read below. - * @param array $seen Every uuid already in the answer. - * @param array $walk The walk. + * @param string $table The magic table. + * @param string $column The parent column. + * @param array $read `parents` (the level to read below), `budget` (the children the cap still + * admits), `seen` (the uuids already in the answer) and `walk`. * - * @return array|null Child uuid => parent uuid. + * @return array{children: array, overflow: bool}|null Child uuid => parent uuid, and whether the level is wider than the budget. */ - private function readableChildren(string $table, string $column, array $parents, array $seen, array $walk): ?array { - if ($parents === []) { - return []; + private function readableChildren(string $table, string $column, array $read): ?array { + if ($read['parents'] === []) { + return ['children' => [], 'overflow' => false]; } + $budget = max(0, $read['budget']); + try { - $children = $this->descender->childrenOf(table: $table, parentColumn: $column, parentUuids: $parents); + $children = $this->descender->childrenOf( + table: $table, + parentColumn: $column, + parentUuids: $read['parents'], + limit: ($budget + 1) + ); } catch (Throwable $e) { $this->logger->warning( message: '[ObjectTreeService] A level of the tree could not be read', @@ -358,16 +398,30 @@ private function readableChildren(string $table, string $column, array $parents, return null; } - $children = array_diff_key($children, $seen); + // Counted before the seen ones are dropped: a row that is a cycle + // still used up a place of the bounded read, so it may hide a wider level. + $overflow = (count($children) > $budget); + $children = array_diff_key($children, $read['seen']); + if ($overflow === true) { + $children = array_slice($children, 0, $budget, true); + } + + $readable = $this->readableOnly(walk: $read['walk'], uuids: $children); + if ($readable === null) { + return null; + } - return $this->readableOnly(walk: $walk, uuids: $children); + return ['children' => $readable, 'overflow' => $overflow]; }//end readableChildren() /** * The ancestors of the root, nearest first. * * The walk stops at the first ancestor the caller may not read: a level - * above a hidden one would carry the wrong level number. + * above a hidden one would carry the wrong level number. A node names the + * uuid of its own parent only when that parent is one the caller may read: + * the topmost node of a walk that stopped at a hidden ancestor, or at one + * that does not exist, carries no parent at all. * * @param ObjectEntity $root The root. * @param array $walk The walk. @@ -375,51 +429,79 @@ private function readableChildren(string $table, string $column, array $parents, * @return array{nodes: array>, truncatedBy: string|null} The nodes, nearest first. */ private function ancestors(ObjectEntity $root, array $walk): array { - $nodes = []; + $chain = []; $seen = [(string)$root->getUuid() => true]; $current = $root; + $truncatedBy = null; + $topParent = null; + + // One step past the depth bound: it only finds out whether there is + // more above, and which readable uuid the topmost node then names. + for ($level = 1; $level <= ($walk['depth'] + 1); $level++) { + $step = $this->parentStep(object: $current, seen: $seen, walk: $walk); + if ($step['object'] === null) { + $truncatedBy = $step['truncatedBy']; + break; + } - for ($level = 1; $level <= $walk['depth']; $level++) { - $parent = $this->readableParent(object: $current, seen: $seen, walk: $walk); - if ($parent === null) { - return ['nodes' => $nodes, 'truncatedBy' => null]; + $uuid = (string)$step['object']->getUuid(); + if ($level > $walk['depth']) { + $topParent = $uuid; + $truncatedBy = self::TRUNCATED_DEPTH; + break; } - $uuid = (string)$parent->getUuid(); $seen[$uuid] = true; - $grandparent = self::reference(value: ($parent->getObject()[$walk['property']] ?? null)); - $nodes[] = $this->node(object: $parent, level: -$level, parent: $grandparent, fields: $walk['fields']); - $current = $parent; + $chain[$uuid] = $step['object']; + $current = $step['object']; } - if ($this->readableParent(object: $current, seen: $seen, walk: $walk) !== null) { - return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_DEPTH]; + $shown = $this->redacted(objects: $chain, fields: $walk['fields'], rbac: $walk['rbac']); + $uuids = array_keys($chain); + $nodes = []; + foreach ($uuids as $index => $uuid) { + $nodes[] = $this->node( + object: ($shown[$uuid] ?? null), + level: -($index + 1), + parent: ($uuids[($index + 1)] ?? $topParent), + fields: $walk['fields'], + uuid: $uuid + ); } - return ['nodes' => $nodes, 'truncatedBy' => null]; + return ['nodes' => $nodes, 'truncatedBy' => $truncatedBy]; }//end ancestors() /** * The parent of one object when it exists, is new to the walk and is readable. * + * Tells "there is none" (or it is a cycle) and "the caller may not read it" + * from "read access could not be established": the last one is an + * incomplete answer, and the tree says so. + * * @param ObjectEntity $object The object. * @param array $seen Every uuid already in the answer. * @param array $walk The walk. * - * @return ObjectEntity|null The parent. + * @return array{object: ObjectEntity|null, truncatedBy: string|null} The parent, or why there is none to show. */ - private function readableParent(ObjectEntity $object, array $seen, array $walk): ?ObjectEntity { + private function parentStep(ObjectEntity $object, array $seen, array $walk): array { $parentUuid = self::reference(value: ($object->getObject()[$walk['property']] ?? null)); if ($parentUuid === null || isset($seen[$parentUuid]) === true) { - return null; + return ['object' => null, 'truncatedBy' => null]; } - if ($this->readableOnly(walk: $walk, uuids: [$parentUuid => '']) === []) { - return null; + $readable = $this->readableOnly(walk: $walk, uuids: [$parentUuid => '']); + if ($readable === null) { + return ['object' => null, 'truncatedBy' => self::TRUNCATED_UNREADABLE]; + } + + if ($readable === []) { + return ['object' => null, 'truncatedBy' => null]; } - return ($this->load(walk: $walk, uuids: [$parentUuid])[$parentUuid] ?? null); - }//end readableParent() + return ['object' => ($this->load(walk: $walk, uuids: [$parentUuid])[$parentUuid] ?? null), 'truncatedBy' => null]; + }//end parentStep() /** * The entries of a uuid-keyed map the caller may read. @@ -427,18 +509,21 @@ private function readableParent(ObjectEntity $object, array $seen, array $walk): * @param array $walk The walk. * @param array $uuids Uuid => anything. * - * @return array The readable entries, in their order. + * @return array|null The readable entries, in their order; null when read access could not be established. */ - private function readableOnly(array $walk, array $uuids): array { + private function readableOnly(array $walk, array $uuids): ?array { if ($uuids === [] || $walk['rbac'] === false) { return $uuids; } - $readable = $this->objectMapper->filterReadableUuids( + $readable = $this->objectMapper->readableUuidsOrNull( registerId: (int)$walk['register']->getId(), schemaId: (int)$walk['schema']->getId(), uuids: array_map('strval', array_keys($uuids)) ); + if ($readable === null) { + return null; + } return array_intersect_key($uuids, array_flip($readable)); }//end readableOnly() @@ -459,10 +544,36 @@ private function load(array $walk, array $uuids): array { ); }//end load() + /** + * The objects as the caller may read them: write-only properties, properties + * the caller's group may not read and encrypted values handled the way every + * other read of an object does. + * + * Only a request that names `fields` has properties to carry, so only then + * is anything done. The entities are copied first: the originals are what + * the walk reads the parent property from, and are never written back. + * + * @param array $objects Uuid => object, as the mapper loaded them. + * @param string[] $fields The properties the request names. + * @param bool $rbac Whether the read is the caller's own (property read access applies). + * + * @return array Uuid => the redacted copy. + */ + private function redacted(array $objects, array $fields, bool $rbac): array { + if ($fields === [] || $objects === []) { + return $objects; + } + + $copies = array_map(static fn (ObjectEntity $object): ObjectEntity => clone $object, $objects); + $this->renderer->redactWriteOnlyFromRows(rows: $copies, _rbac: $rbac); + + return $copies; + }//end redacted() + /** * One node of the answer. * - * @param ObjectEntity|null $object The object, when it loaded. + * @param ObjectEntity|null $object The object as the caller may read it, when it loaded. * @param int $level 0 for the root, negative above, positive below. * @param string|null $parent The parent uuid. * @param string[] $fields Properties to copy. diff --git a/lib/Service/Schemas/PropertyValidatorHandler.php b/lib/Service/Schemas/PropertyValidatorHandler.php index 6294abee11..9306605b89 100644 --- a/lib/Service/Schemas/PropertyValidatorHandler.php +++ b/lib/Service/Schemas/PropertyValidatorHandler.php @@ -26,6 +26,7 @@ namespace OCA\OpenRegister\Service\Schemas; use Exception; +use OCA\OpenRegister\Service\Object\ReferenceKey; use OCA\OpenRegister\Service\Search\PropertySearchProfile; /** @@ -977,8 +978,10 @@ public function validateProperty(array $property, string $path = ''): bool { } // A reference by key only refuses: the other actions rewrite or - // delete by uuid and would act on the wrong value. - $referenceKey = ($property['referenceKey'] ?? ($property['items']['referenceKey'] ?? null)); + // delete by uuid and would act on the wrong value. An empty + // referenceKey or the literal `uuid` is a plain uuid reference, + // read the way the delete reads it. + $referenceKey = (new ReferenceKey())->keyOf(property: $property); if ($referenceKey !== null && strtoupper((string)$property['onDelete']) !== 'RESTRICT') { throw new Exception("'referenceKey' at '$path' is only valid with onDelete RESTRICT"); } diff --git a/lib/Service/Search/ObjectSearchResultFormatter.php b/lib/Service/Search/ObjectSearchResultFormatter.php index 8e805b573e..e37f6c4fad 100644 --- a/lib/Service/Search/ObjectSearchResultFormatter.php +++ b/lib/Service/Search/ObjectSearchResultFormatter.php @@ -190,9 +190,10 @@ private function resolveObjectUrl(array $object, array $selfData, array $ids): s objectData: $flatData ); - // A placeholder the object could not fill (its `case` is empty) would - // leave a literal `{case}` in the link. Such a hit opens OpenRegister's - // own page instead of a broken one. + // A placeholder the object could not fill (its `case` is absent or empty: + // ownLinkProperties() passes neither) would leave a literal `{case}` in + // the link. Such a hit opens OpenRegister's own page instead of a broken + // one. if ($objectUrl !== null && preg_match('/\{[A-Za-z0-9_]+\}/', $objectUrl) !== 1) { return $objectUrl; } @@ -206,7 +207,11 @@ private function resolveObjectUrl(array $object, array $selfData, array $ids): s /** * The object's own properties a deep-link template may name: declared * (not `@self`, not an `_`-prefixed key the pipeline attached, the same - * line {@see buildExcerpt()} draws) and scalar. Values are URL-encoded, + * line {@see buildExcerpt()} draws) and scalar, and holding a value. An + * empty or blank string and a boolean are left out, so the placeholder + * stays unfilled and the hit falls back to OpenRegister's own page: filled + * with nothing, `/apps/dossiq/cases/{case}` would become `/apps/dossiq/cases/` + * and no brace would be left to notice it by. Values are URL-encoded, * because they land in a path and a value like `../settings` must not * walk out of it. * @@ -224,7 +229,7 @@ private function ownLinkProperties(array $object): array { continue; } - if (is_scalar($value) === false) { + if (is_scalar($value) === false || is_bool($value) === true || trim((string)$value) === '') { continue; } 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/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/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php b/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php index d3e2f82238..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,6 +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) + ); if ($record === true) { $this->baselines->record( @@ -173,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); @@ -199,15 +197,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 +217,110 @@ 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. + * + * 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 = $this->withAuthorizationOf(target: $incoming, live: $live); + + $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; + } + + $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. + * + * 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 +626,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/lib/Service/Task/TaskBlockerGuard.php b/lib/Service/Task/TaskBlockerGuard.php index d3339e53be..6afbfef020 100644 --- a/lib/Service/Task/TaskBlockerGuard.php +++ b/lib/Service/Task/TaskBlockerGuard.php @@ -81,7 +81,15 @@ public function refuseHandWrittenState(array $data): void { /** * Refuse a blocker that is the task itself, does not exist, or closes a loop. * - * @param Task $task The task about to be created, uuid already set. + * With `$mayRead`, the caller's own read rule: a blocker the caller may not + * read is refused in the words of one that does not exist. Without that, a + * person who knows a task uuid could tell from the answer (and later from + * the `blocked` flag of their own task) that a task of somebody else's + * exists and whether it is still open. Trusted callers (flows, migrations) + * pass none: they declare blockers of their own making. + * + * @param Task $task The task about to be created, uuid already set. + * @param callable|null $mayRead Answers whether the caller may read a given Task; null for a trusted caller. * * @return void * @@ -89,7 +97,7 @@ public function refuseHandWrittenState(array $data): void { * * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ - public function assertDeclarable(Task $task): void { + public function assertDeclarable(Task $task, ?callable $mayRead = null): void { $blockedBy = (string)$task->getBlockedBy(); if ($blockedBy === '') { return; @@ -113,6 +121,10 @@ public function assertDeclarable(Task $task): void { return; } + if ($this->isHiddenFromCaller(blocker: $blocker, named: ($next === $blockedBy), mayRead: $mayRead) === true) { + throw new TaskValidationException(message: sprintf("blockedBy names task '%s', and no task has that uuid.", $blockedBy)); + } + $next = (string)$blocker->getBlockedBy(); if ($next === $own) { throw new TaskValidationException( @@ -121,4 +133,64 @@ public function assertDeclarable(Task $task): void { } } }//end assertDeclarable() + + /** + * Whether the caller may not read the blocker they named. + * + * Only the task the caller NAMES is held to their read rule: the chain + * behind it is walked for loops and never reported back. + * + * @param Task $blocker The blocker found. + * @param bool $named Whether it is the one the caller named, not one further up its chain. + * @param callable|null $mayRead The caller's read rule; null for a trusted caller. + * + * @return bool True when it must be refused as if it did not exist. + */ + private function isHiddenFromCaller(Task $blocker, bool $named, ?callable $mayRead): bool { + return $named === true && $mayRead !== null && $mayRead($blocker) !== true; + }//end isHiddenFromCaller() + + /** + * Refuse a task that waits on an open blocker as one that does not exist. + * + * For a seam that never lists a held-back task (the portal): reading or + * completing one by its uuid must not be a way around the list. + * + * @param Task $task The task. + * + * @return void + * + * @throws DoesNotExistException When the task waits on a blocker that is still open. + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + public function assertNotHeldBack(Task $task): void { + if ($this->isHeldBack(task: $task) === true) { + throw new DoesNotExistException('No such task.'); + } + }//end assertNotHeldBack() + + /** + * Whether the task waits on a blocker that is still open. + * + * A blocker that no longer exists blocks nothing. + * + * @param Task $task The task. + * + * @return bool True when its blocker exists and is not terminal. + * + * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight + */ + public function isHeldBack(Task $task): bool { + $blockedBy = (string)$task->getBlockedBy(); + if ($blockedBy === '') { + return false; + } + + try { + return $this->tasks->findByUuid(uuid: $blockedBy)->getIsTerminal() !== true; + } catch (DoesNotExistException) { + return false; + } + }//end isHeldBack() }//end class diff --git a/lib/Service/Task/TaskService.php b/lib/Service/Task/TaskService.php index 0ca82ba984..e988c7fde0 100644 --- a/lib/Service/Task/TaskService.php +++ b/lib/Service/Task/TaskService.php @@ -257,7 +257,14 @@ public function create(array $data, ?string $actor): Task { } } - return $this->import(data: $data, actor: $actor); + // An ordinary caller may only name a blocker they may read; an + // administrator reads every task, so for them it could only pass. + $mayReadBlocker = null; + if ($this->authorization->isAdministrator(uid: $actor) === false) { + $mayReadBlocker = fn (Task $blocker): bool => $this->authorization->mayRead(task: $blocker, uid: $actor); + } + + return $this->import(data: $data, actor: $actor, mayReadBlocker: $mayReadBlocker); }//end create() /** @@ -268,6 +275,8 @@ public function create(array $data, ?string $actor): Task { * * @param array $data The task fields, canonical or legacy. * @param string|null $actor The creating identity. + * @param callable|null $mayReadBlocker Answers whether the creating caller may read a blocker; create() passes + * the caller's own rule for an ordinary user, a trusted caller passes none. * * @return Task The created task, with candidates indexed and creation audited. * @@ -277,11 +286,11 @@ public function create(array $data, ?string $actor): Task { * @spec openspec/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight */ - public function import(array $data, ?string $actor): Task { + public function import(array $data, ?string $actor, ?callable $mayReadBlocker = null): Task { $data = $this->locate(data: $data); $task = $this->builder->fromData(data: $data, actor: $actor); $this->refuseUnrenderableForm(task: $task); - (new TaskBlockerGuard(tasks: $this->tasks))->assertDeclarable(task: $task); + (new TaskBlockerGuard(tasks: $this->tasks))->assertDeclarable(task: $task, mayRead: $mayReadBlocker); $this->authorizeOrRecord(verb: 'create', task: $task, actor: $actor); $this->pending = [ 'assignee' => null, 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/openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md b/openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md index fa705d3be5..bbc543dc36 100644 --- a/openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md +++ b/openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md @@ -11,13 +11,19 @@ NOT be stored. A create that carries `blocked`, or a state of `blocked`, SHALL be refused. A create SHALL be refused when `blockedBy` names the task itself, names no -existing task, or closes a loop of blockers. +existing task, or closes a loop of blockers. A caller who is not an administrator +SHALL be refused a blocker they may not read in the words of one that does not exist, +so `blockedBy` is no way to probe for a task. An inbox read SHALL leave out every blocked task, in its results and in its total. A read anchored to an object or a run SHALL still list blocked tasks, carrying `blocked` and `blockedBy`. A read with `includeBlocked=true` SHALL list them in any scope. +The portal list of an external task SHALL leave out a blocked task, in its results +and in its total, and a portal party SHALL NOT be able to read or complete one by its +uuid: it answers like a task that does not exist, until the blocker is terminal. + When a blocker reaches a terminal state, every open task it blocked SHALL be released with no person acting: it SHALL get an audit entry `released` naming the blocker, it SHALL be announced again to the projections, and it SHALL @@ -60,3 +66,18 @@ return to the inbox. - **GIVEN** task A blocked by task B - **WHEN** a task is created blocked by itself, by a uuid no task has, or as B's blocker with A's chain leading back to it - **THEN** the create SHALL be refused + +#### Scenario: A blocked portal task waits out of sight in the portal too +@e2e exclude unit; TaskMapperExternalTest covers the predicate, PortalTaskServiceTest the read and the completion + +- **GIVEN** an external task assigned to a party, blocked by an open task +- **WHEN** the party lists their portal tasks, reads it by its uuid, or tries to complete it +- **THEN** it SHALL NOT be listed or counted, and reading or completing it SHALL answer as for a task that does not exist +- **AND** once the blocker is completed the task SHALL be listed and completable again + +#### Scenario: A blocker the caller may not read is refused like a missing one +@e2e exclude unit; TaskBlockedByCreateTest + +- **GIVEN** a task of another organisation that the caller may not read +- **WHEN** the caller creates a task blocked by it +- **THEN** the create SHALL be refused with the same message as for a uuid no task has 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..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 @@ -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 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 a8d185f402..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,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, 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 - 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 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`, 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 - WHEN the buffer is flushed diff --git a/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md b/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md index 8d1d34c824..1f307aca02 100644 --- a/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md +++ b/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md @@ -147,7 +147,7 @@ A draft SHALL have a lifetime of 30 days unless its schema says otherwise (decis OpenRegister SHALL run one background job, declared where it declares its jobs (`appinfo/info.xml`), once a day (decision 216). For every schema that stores its own objects and has a draft lifetime, in every register that carries it, the job SHALL remove each object in status `draft` whose last change (`updated`, else `created`) is older than that lifetime. It SHALL remove nothing else: an object that is not a draft, a draft of a schema that opts out, and a draft without any date SHALL stay. A schema served from an object source SHALL NOT be asked for drafts. Removal SHALL go through the ordinary object delete (`ObjectService::deleteObject()`), so its guards apply and the draft's files go with it; a delete that path refuses SHALL be logged and counted, and the sweep SHALL carry on. -The work of one run SHALL be bounded (decision 228): at most 1,000 delete attempts per run, and at most 5,000 drafts read per register and schema. What is left SHALL be counted as deferred and SHALL wait for the next run. The job SHALL log one line per register and schema that held an expired draft (register, schema, lifetime, expired, removed, refused, deferred) and one line with the totals, a zero included, and SHALL surface a failure rather than report success. Generic by construction (decision 182): no app carries a draft cleanup of its own. +The work of one run SHALL be bounded (decision 228): at most 1,000 delete attempts per run, and at most 5,000 drafts read per register and schema, the store being asked for the drafts past their lifetime alone (last changed before the cutoff, or never changed and created before it) so that the rows read are expired rows. What is left, beyond the delete attempts and beyond the rows read, SHALL be counted as deferred and SHALL wait for the next run, so that expired = removed + refused + deferred. The job SHALL log one line per register and schema that held an expired draft (register, schema, lifetime, expired, removed, refused, deferred) and one line with the totals, a zero included, at a level the default log level shows (warning) whenever a draft was removed or refused, and SHALL surface a failure rather than report success. Generic by construction (decision 182): no app carries a draft cleanup of its own. Because the default applies to drafts that exist at the upgrade, the first run after it removes drafts that were kept until then. The changelog and the release notes SHALL say so. diff --git a/openspec/changes/example-sets/specs/example-sets/spec.md b/openspec/changes/example-sets/specs/example-sets/spec.md index ce521c6911..cb51574892 100644 --- a/openspec/changes/example-sets/specs/example-sets/spec.md +++ b/openspec/changes/example-sets/specs/example-sets/spec.md @@ -26,7 +26,7 @@ The loader SHALL read `lib/Settings/example-sets/.json` from every app that ### Requirement: Every placeholder resolves or the set is refused (REQ-EXS-002) -Declared values SHALL support `{{cast:.}}` (a dot path into a cast person, organisation or group), `{{date:}}` and `{{datetime: HH:MM}}` (n days from the install day in the set's time zone), `{{object:.}}` (a field of an object declared earlier in the SAME fragment) and `{{lookup:}}` (the id of the one row a lookup by business key finds, in any register). A value that is exactly one placeholder SHALL take the type of what it stands for; a placeholder inside a longer string SHALL be interpolated as text. A placeholder that cannot be resolved SHALL be an error naming the file, the object key and the placeholder; it SHALL NEVER become an empty string. +Declared values SHALL support `{{cast:.}}` (a dot path into a cast person, organisation or group), `{{date:}}` and `{{datetime: HH:MM}}` (n days from the install day in the set's time zone), `{{object:.}}` (a field of an object declared earlier in the SAME fragment) and `{{lookup:}}` (the id of the one row a lookup by business key finds, in any register; a lookup that more than one row answers, which two organisations holding the same key do, SHALL be refused naming the count, never resolved to whichever row comes first). A value that is exactly one placeholder SHALL take the type of what it stands for; a placeholder inside a longer string SHALL be interpolated as text. A placeholder that cannot be resolved SHALL be an error naming the file, the object key and the placeholder; it SHALL NEVER become an empty string. #### Scenario: A cast field fills a value - **GIVEN** a cast person `sanne` with `bsn` `999994001` @@ -68,7 +68,7 @@ The loader SHALL create a Nextcloud account for every cast person with a `uid`, ### Requirement: Check validates every object against its real schema without writing (REQ-EXS-004) -`--check` SHALL resolve every placeholder and lookup, verify each target register and schema exists on the instance, check every cast `bsn` against the eleven test, and validate every object with OpenRegister's own object validator against its target schema, whether or not that schema has hard validation on. It SHALL write none of the set's accounts, groups, objects or changes; the only thing it may write is the import of a register the set requires (REQ-EXS-009), because those schemas are what it checks against. It SHALL exit non-zero when anything is refused and name each refusal. An install SHALL run the same pass first and write none of the set's accounts, groups, objects or changes when it refuses. A reference to an object of the set that is not written yet SHALL be validated with a stand-in of the right shape, and a lookup SHALL also match an object declared earlier in the set. An account id the set creates (a cast member's `uid` or a service account's `uid`) SHALL pass a property of format `user` in the check although the account does not exist yet, because an install writes every account before any object; any other id that names no existing account SHALL still be refused, in an object and in a change. The validator SHALL accept those ids for the set's own validation only. +`--check` SHALL resolve every placeholder and lookup, verify each target register and schema exists on the instance, check every cast `bsn` against the eleven test, and validate every object with OpenRegister's own object validator against its target schema, whether or not that schema has hard validation on. It SHALL write none of the set's accounts, groups, objects or changes; the only things it may write are the import of a register the set requires (REQ-EXS-009), because those schemas are what it checks against, and the OpenRegister organisations the cast declares and the instance lacks (REQ-EXS-016), because `{{organisation:...}}` placeholders resolve to them; both are recorded, and remove takes them away again. The command SHALL say so in its `--check` option text and in its closing output. It SHALL exit non-zero when anything is refused and name each refusal. An install SHALL run the same pass first and write none of the set's accounts, groups, objects or changes when it refuses. A reference to an object of the set that is not written yet SHALL be validated with a stand-in of the right shape, and a lookup SHALL also match an object declared earlier in the set. An account id the set creates (a cast member's `uid` or a service account's `uid`) SHALL pass a property of format `user` in the check although the account does not exist yet, because an install writes every account before any object; any other id that names no existing account SHALL still be refused, in an object and in a change. The validator SHALL accept those ids for the set's own validation only. #### Scenario: A value the schema refuses fails the check - **GIVEN** an object whose `status` is not in its schema's enum @@ -101,7 +101,7 @@ The loader SHALL create a Nextcloud account for every cast person with a `uid`, ### Requirement: A second install writes nothing (REQ-EXS-005) -The loader SHALL keep an install record per set in OpenRegister's app config, listing the accounts it created or adopted, the groups it created, the memberships it added and the id of every object it wrote under `/`. A second install SHALL write nothing that the record holds and still finds on the instance, and SHALL report each as already there. An object the record holds but the instance lost SHALL be written again. +The loader SHALL keep an install record per set in OpenRegister's app config, listing the accounts it created or adopted, the groups it created, the memberships it added and the id of every object it wrote under `/`. A second install SHALL write nothing that the record holds and still finds on the instance, and SHALL report each as already there. An object the record holds but the instance lost SHALL be written again; only a read that says the object does not exist counts as lost, and an object whose read FAILED (a database or magic-table error, a schema that cannot be resolved) SHALL NOT be written again: the install writes nothing for it, keeps the recorded id, reports it as not written, and the next install tries the read again. #### Scenario: A repeated install is a no-op - **GIVEN** a complete install of set `demo` @@ -119,7 +119,7 @@ After writing, the loader SHALL read every object it created in this run back fr ### Requirement: Remove deletes exactly what the install made (REQ-EXS-007) -`openregister:example-set:remove ` SHALL delete, in reverse order of writing, every object the record holds, then the memberships it added, then the groups it created, then the accounts it created. It SHALL NOT touch an adopted account, a pre-existing group, or any object it did not write. What it cannot delete SHALL stay in the record and be named, with exit 2. +`openregister:example-set:remove ` SHALL delete, in reverse order of writing, every object the record holds, then the memberships it added, then the groups it created, then the accounts it created. It SHALL NOT touch an adopted account, a pre-existing group, or any object it did not write. What it cannot delete SHALL stay in the record and be named, with exit 2; an object whose read before the delete failed (anything but "it does not exist") cannot be told gone, so it stays and is named with the reason. #### Scenario: Remove leaves foreign data alone - **GIVEN** an object in the same schema that the set did not write @@ -137,13 +137,18 @@ After writing, the loader SHALL read every object it created in this run back fr ### Requirement: A set imports the OpenRegister registers it requires (REQ-EXS-009) -The cast file MAY name OpenRegister registers in `requires`. An entry SHALL be either a register slug (for example `"publication"`), imported from OpenRegister's real register descriptor, or `{"register": "", "mock": true}`, imported from OpenRegister's mock (sample) descriptor that declares the slug, the way `occ openregister:descriptors:list --app=openregister --import= --mock` does. An example set is sample data, so it MAY require a register OpenRegister ships only as a mock (zuiddrecht requires `brp` and `kvk` that way). An entry that names no register slug SHALL refuse the set when it is gathered. Before the check, the loader SHALL import every required register that is not on the instance from the descriptor its entry names, and SHALL record the import jobs that import created. A required register that is already on the instance SHALL be used as it is and SHALL NOT be recorded. A second run SHALL import nothing. Remove SHALL soft-delete exactly the objects of the recorded import jobs, after the set's own objects, and SHALL NOT touch an import it did not make. A required register that cannot be imported SHALL refuse the set. +The cast file MAY name OpenRegister registers in `requires`. An entry SHALL be either a register slug (for example `"publication"`), imported from OpenRegister's real register descriptor, or `{"register": "", "mock": true}`, imported from OpenRegister's mock (sample) descriptor that declares the slug, the way `occ openregister:descriptors:list --app=openregister --import= --mock` does. An example set is sample data, so it MAY require a register OpenRegister ships only as a mock (zuiddrecht requires `brp` and `kvk` that way). An entry that names no register slug SHALL refuse the set when it is gathered. Before the check, the loader SHALL import every required register that is not on the instance from the descriptor its entry names, and SHALL record the import job that import reports it ran under (with the configuration id it ran under), never a job found by comparing the jobs the instance holds before and after, which would claim an import another process recorded meanwhile. A required register that is already on the instance SHALL be used as it is and SHALL NOT be recorded. A second run SHALL import nothing. Remove SHALL soft-delete exactly the objects of the recorded import jobs, after the set's own objects, and SHALL NOT touch an import it did not make. The register and its schemas stay on the instance, as they may hold other people's data; so that a later install does not find a register without its data, the record keeps the slug of a register whose import was removed (without jobs, and not counted as an install), and the next install imports that register's data again although the register exists. A required register that cannot be imported SHALL refuse the set. #### Scenario: A missing register is imported once and removed again - **GIVEN** a set that requires `brp` and an instance without it - **WHEN** the set is installed twice and then removed - **THEN** `brp` SHALL be imported by the first run only, reported as `imported` then `kept`, and remove SHALL delete the objects of that import +#### Scenario: A register whose data was removed is imported again +- **GIVEN** a set that requires `brp`, installed on an instance without it and then removed +- **WHEN** the set is installed again +- **THEN** `brp` SHALL be imported again (its data was removed although the register stayed), reported as `imported`, and remove SHALL delete the objects of that second import + #### Scenario: A register that was there stays there - **GIVEN** a set that requires `brp` and an instance that already has it - **WHEN** the set is installed and removed @@ -175,7 +180,7 @@ A cast group or fragment group whose gid is `admin` (in any letter case) SHALL r ### Requirement: A set may change an object it did not create and undo exactly that (REQ-EXS-011) -A fragment MAY declare `changes`: each with a `key`, a `register`, a `schema`, a `where` that finds exactly one existing object by business key, and `set` (dot path to value) and/or `append` (dot path to one value added to the list there). The check SHALL refuse a change whose object is not found or whose result its schema refuses. Install SHALL write only what differs, SHALL record the previous value of every path it set, the values it appended, and any map it created on the way to a path. A change already fully present SHALL NOT be recorded. Remove SHALL put back exactly the recorded values and leave every other part of the object as it is then. +A fragment MAY declare `changes`: each with a `key`, a `register`, a `schema`, a `where` that finds exactly one existing object by business key, and `set` (dot path to value) and/or `append` (dot path to one value added to the list there). The check SHALL refuse a change whose `where` finds no object or more than one (naming the count; two organisations may hold the same business key) or whose result its schema refuses. Install SHALL write only what differs, SHALL record the previous value of every path it set, the values it appended, and any map it created on the way to a path. A change already fully present SHALL NOT be recorded. Remove SHALL put back exactly the recorded values and leave every other part of the object as it is then. A path whose value is no longer the one the change wrote (somebody edited it after the install) SHALL be left as it is and named in the output as left in place, as for app config and user preferences. #### Scenario: A sign-in mode added to a portal is taken off again - **GIVEN** a portal row with sign-in modes `["password"]` that the set did not create @@ -226,7 +231,7 @@ A fragment MAY declare `serviceAccounts`, each with a `key`, a `uid`, a `display ### Requirement: A fragment may set its own app's config and undo exactly that (REQ-EXS-015) -A fragment MAY declare `appConfig`, each entry a `key` and a `value` (placeholders allowed) or `generate: true`. An entry SHALL only set config of the app that ships the fragment; naming another app SHALL refuse the set. A key the app keeps as sensitive SHALL be refused unless the set generates the value; a generated value SHALL be stored as sensitive and SHALL never be printed or recorded. Install SHALL write only a value that differs and SHALL record the old value. Remove SHALL put the old value back, or delete the key when there was none, but SHALL leave a key whose value changed after the install, naming it. Output SHALL name keys, never values. +A fragment MAY declare `appConfig`, each entry a `key` and a `value` (placeholders allowed) or `generate: true`. An entry SHALL only set config of the app that ships the fragment; naming another app SHALL refuse the set. A key the app keeps as sensitive SHALL be refused unless the set generates the value; a generated value SHALL be stored as sensitive and SHALL never be printed or recorded; the record holds only a one-way fingerprint of it, so that remove can tell the generated value from one somebody put there. Install SHALL write only a value that differs and SHALL record the old value. Remove SHALL put the old value back, or delete the key when there was none, but SHALL leave a key whose value changed after the install (a generated key whose value no longer has the recorded fingerprint included), naming it; a generated key that existed with an empty value gets that value back. Output SHALL name keys, never values. #### Scenario: The background account is configured and the old value comes back - **GIVEN** dossiq's `woo_publication_catalog_slug` holds `publication` @@ -240,13 +245,18 @@ A fragment MAY declare `appConfig`, each entry a `key` and a `value` (placeholde ### Requirement: A set finds OpenRegister organisations and creates the ones its cast needs (REQ-EXS-016) -`{{organisation:}}` SHALL fill the uuid of an OpenRegister organisation (the tenant entity, not an object), a slug winning over a name. The cast MAY declare `openregisterOrganisations` (key, name, slug, description). Before the check the loader SHALL create each one whose slug is not on the instance and record it; remove SHALL delete only the organisations the set created, last. +`{{organisation:}}` SHALL fill the uuid of an OpenRegister organisation (the tenant entity, not an object), a slug winning over a name. The cast MAY declare `openregisterOrganisations` (key, name, slug, description). Before the check the loader SHALL create each one whose slug is not on the instance and record it; remove SHALL delete only the organisations the set created, last. Only a lookup that says the organisation does not exist SHALL read as absent (an install creates it) or as already gone (remove forgets it); any other error SHALL refuse the install (nothing is created) or leave the organisation in the record to be tried again. Remove SHALL NOT delete an organisation that has a member other than the accounts the set made or adopted (a leftover membership of an account that no longer exists does not count): it SHALL stay, stay in the record, and be named as left in place. #### Scenario: The municipality becomes an organisation once - **GIVEN** the cast declares organisation `zuiddrecht` and the instance has none with that slug - **WHEN** the set is installed twice and removed - **THEN** it SHALL be created once, fill `{{organisation:zuiddrecht}}`, and be gone after remove +#### Scenario: An organisation somebody joined stays +- **GIVEN** an organisation the set created, to which an administrator added a real account +- **WHEN** the set is removed +- **THEN** the organisation SHALL stay and stay in the record, and the output SHALL name it as left in place + ### Requirement: The instance's address fills a placeholder (REQ-EXS-017) `{{instance:baseUrl}}` SHALL fill Nextcloud's `overwrite.cli.url` without a trailing slash. When the instance sets no address the placeholder SHALL be refused, never filled with nothing. @@ -327,13 +337,18 @@ Removing an example set is an administrative act on data the set itself wrote, n ### Requirement: A kept object follows the objects it references (REQ-EXS-023) -When an install keeps an object an earlier run wrote, it SHALL resolve again every top-level field whose declared value holds an `{{object:}}` or `{{lookup:}}` placeholder and SHALL write only a field whose stored value differs, so a reinstall after a partial remove does not leave a kept object pointing at an object that was deleted and written again. The write SHALL be recorded as the change `/#references` with the value it replaced (the first replaced value is kept over later refreshes), the install SHALL prove it like any change (REQ-EXS-011), and remove SHALL stay exact. A kept object whose references already hold their values SHALL not be written. +When an install keeps an object an earlier run wrote, it SHALL resolve again every top-level field whose declared value holds an `{{object:}}` or `{{lookup:}}` placeholder and SHALL write only a field whose stored value differs AND is still the value the install wrote there (the record keeps what each object was created with in its reference fields, and a refresh keeps its newest value), so a reinstall after a partial remove does not leave a kept object pointing at an object that was deleted and written again. A field that holds any other value, or that the record holds no written value for, is somebody's edit: it SHALL be left as it is and named in the output (`Left as it is`), and it SHALL NOT make the install fail. The write SHALL be recorded as the change `/#references` with the value it replaced (the first replaced value is kept over later refreshes), the install SHALL prove it like any change (REQ-EXS-011), and remove SHALL stay exact. A kept object whose references already hold their values SHALL not be written. #### Scenario: A kept publication follows its theme - **GIVEN** a set whose publication references a theme by `{{object:theme-1.id}}` and another by a lookup, and a remove that deleted the theme but could not delete the publications - **WHEN** the set is installed again - **THEN** the theme SHALL be written with a new id, the publications SHALL be kept and point at the new theme, and the refresh SHALL be recorded with the old id +#### Scenario: A user's edit of a reference is left +- **GIVEN** a kept publication whose theme reference a user pointed at an unrelated id after the install +- **WHEN** the set is installed again +- **THEN** the reference SHALL keep the user's value, no `#references` change SHALL be recorded, and the output SHALL name the field as left as it is + #### Scenario: A refreshed set installs again without writes - **GIVEN** a set whose references were refreshed - **WHEN** it is installed again and then removed 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..478ace7369 100644 --- a/openspec/changes/form-destination-validator/specs/form-destination/spec.md +++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md @@ -30,7 +30,7 @@ ### Requirement: A submit MUST create the destination in one request and return its reference -`FormSubmitService::submit(destination, mapping, payload, subject, idempotencyKey, scope)` and `submitAll(writes, payload, subject, idempotencyKey, scope)` SHALL map the payload through the mapping (only mapped fields and fixed values reach the object; without a mapping the payload minus `_` and `@` keys is the object), validate it with the destination schema's full validator regardless of the schema's hard-validation flag (properties the server fills are excused from `required`), then create the object under the subject's RBAC; an anonymous submit is saved unowned. It SHALL return `reference` (the property marked `x-openregister.reference: true`, else the uuid), `id`, `receivedAt` (the object's created moment), a `confirmation` map of every property marked `x-openregister.confirmation: true`, read after every create listener ran, and `objects[]` listing each write. A refused payload SHALL answer 422 `{ message, findings }`; a subject not allowed to create 403; an unknown destination 404; an unreachable destination 503 "try again later" with nothing created (Q3). `POST /api/forms/{formId}/submit` SHALL wrap it for forms stored in OpenRegister, with `#[PublicPage]`, `#[AnonRateLimit]`, `#[UserRateLimit]` and `#[BruteForceProtection]` per ADR-082; an unknown, unpublished or destination-less form SHALL answer one identical 404 and register a brute-force attempt; a filled honeypot field `_hp` SHALL answer 202 and store nothing; a form whose `audience` is not `public` SHALL answer 401 to an anonymous visitor. A form stored in OpenRegister is any object whose body carries `status: published` (or `published: true`), `destination: { register, schema }` or `writes[]`, and optionally `mapping` and `audience`. +`FormSubmitService::submit(destination, mapping, payload, subject, idempotencyKey, scope)` and `submitAll(writes, payload, subject, idempotencyKey, scope)` SHALL map the payload through the mapping (only mapped fields and fixed values reach the object; without a mapping the payload minus `_` and `@` keys and minus `id` and `uuid` is the object; a submit never chooses the uuid of the object it writes, so an `id` or `@self.id` is dropped from the object whatever the mapping says, and the only way to name an existing object is the draft id), validate it with the destination schema's full validator regardless of the schema's hard-validation flag (properties the server fills are excused from `required`), then create the object under the subject's RBAC; an anonymous submit is saved unowned. It SHALL return `reference` (the property marked `x-openregister.reference: true`, else the uuid), `id`, `receivedAt` (the object's created moment), a `confirmation` map of every property marked `x-openregister.confirmation: true`, read after every create listener ran, and `objects[]` listing each write. A refused payload SHALL answer 422 `{ message, findings }`; a subject not allowed to create 403; an unknown destination 404; an unreachable destination 503 "try again later" with nothing created (Q3). `POST /api/forms/{formId}/submit` SHALL wrap it for forms stored in OpenRegister, with `#[PublicPage]`, `#[AnonRateLimit]`, `#[UserRateLimit]` and `#[BruteForceProtection]` per ADR-082; an unknown, unpublished or destination-less form SHALL answer one identical 404 and register a brute-force attempt; a filled honeypot field `_hp` SHALL answer 202 and store nothing; a form whose `audience` is not `public` SHALL answer 401 to an anonymous visitor. A form stored in OpenRegister is any object whose body carries `status: published` (or `published: true`), `destination: { register, schema }` or `writes[]`, and optionally `mapping` and `audience`. #### Scenario: The response carries the listener-computed fields @@ -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. @@ -62,7 +74,7 @@ The service SHALL validate every write before the first. When a write is refused ### Requirement: Upload tokens MUST hold bytes only and expire -`POST /api/forms/{formId}/uploads` SHALL accept one file and the destination `property` it is for, check it against that property's file rules (`maxSize` in bytes or `fileConfiguration.maxSize` in MB, `allowedTypes` or `fileConfiguration.allowedMimeTypes`, the same reader the save path uses), and return `{ token, expiresAt }`. A submit SHALL claim tokens by a payload value `{ "uploadToken": "" }` for the property the token was issued for, and only after its last write succeeded, so a refused submit leaves the files for the retry. Unclaimed tokens SHALL be deleted after 24 hours by an hourly background job that logs its count, a zero included. +`POST /api/forms/{formId}/uploads` SHALL accept one file and the destination `property` it is for, check it against that property's file rules (`maxSize` in bytes or `fileConfiguration.maxSize` in MB, `allowedTypes` or `fileConfiguration.allowedMimeTypes`, the same reader the save path uses), and return `{ token, expiresAt }`. A submit SHALL claim tokens by a payload value `{ "uploadToken": "" }` for the property the token was issued for, and only after its last write succeeded, so a refused submit leaves the files for the retry. A draft save SHALL claim its tokens the same way once the draft is written, because the files then sit on the draft. Unclaimed tokens SHALL be deleted after 24 hours by an hourly background job that logs its count, a zero included. #### Scenario: An oversized file is refused at upload @@ -72,7 +84,7 @@ The service SHALL validate every write before the first. When a write is refused ### Requirement: Saving a schema MUST re-check the forms that submit into it -When a schema's properties or required list are saved, OpenRegister SHALL dispatch `FormDestinationDependentsEvent` with the proposed definition; each owning app adds its published forms into that schema, and the validator SHALL run for each. The owning app's setting `formDestinationBreak` (`unpublish`, the default, or `refuse`) SHALL decide the outcome (Q7): any `refuse` blocks the save with 409 listing the forms and nothing is unpublished; otherwise, after the save, each broken form is unpublished through its owning app, its author is notified (`form_unpublished`), and the save response SHALL list every affected form with its outcome and findings under `affectedForms`. This applies from the first release (Q9). +When a schema's properties or required list are saved, OpenRegister SHALL dispatch `FormDestinationDependentsEvent` with the proposed definition; each owning app adds its published forms into that schema, and the validator SHALL run for each. The owning app's setting `formDestinationBreak` (`unpublish`, the default, or `refuse`) SHALL decide the outcome (Q7): any `refuse` blocks the save with 409 listing the forms and nothing is unpublished; otherwise, after the save, each broken form is unpublished through its owning app, its author is notified (`form_unpublished`), and the save response SHALL list every affected form with its outcome and findings under `affectedForms`. This applies from the first release (Q9). It holds for every writer of a schema, not only the schema API: a schema written by the configuration importer, the import endpoint or the AI and MCP tools has no response to refuse, so the broken forms are unpublished and their authors notified there too (an owning app's `refuse` setting is logged as not honourable), and the schema API, which judges the forms itself, claims its own save so no form is unpublished twice. #### Scenario: A new required property unpublishes a form that lacks it @@ -80,6 +92,12 @@ When a schema's properties or required list are saved, OpenRegister SHALL dispat - **WHEN** `priority` becomes required - **THEN** the form is unpublished, the response names it, and its author is notified +#### Scenario: A schema import that adds a required property unpublishes a form that lacks it + +- **GIVEN** a published form into `ticket` without `priority` +- **WHEN** a configuration import makes `priority` required on `ticket` +- **THEN** the form is unpublished and its author is notified, as for an edit through the schema API + ### Requirement: A create refusal MUST use the per-property error shape `ObjectsController::create` SHALL answer a validation failure through the same handler update and patch use, so the body is the same `{ status, message, errors: [{ property, message }] }` and the status is the same (400 today; moving all three to 422 is question Q-openregister-F1). diff --git a/openspec/changes/object-archive-state/specs/object-lifecycle/spec.md b/openspec/changes/object-archive-state/specs/object-lifecycle/spec.md index f1dcf91985..5c1555c951 100644 --- a/openspec/changes/object-archive-state/specs/object-lifecycle/spec.md +++ b/openspec/changes/object-archive-state/specs/object-lifecycle/spec.md @@ -186,7 +186,7 @@ withdrawing and locking SHALL be audit facts. ### Requirement: File writes honour the frozen and archived marker (REQ-OAS-007) -Every write to a file attached to a frozen or archived object SHALL be refused: creating, replacing, updating, renaming, moving out or in, deleting, locking and unlocking, through the OpenRegister file API and through Nextcloud Files or WebDAV in the object's folder. The refusal SHALL use `ObjectStateWriteException` and SHALL name the state, the actor, the moment and the reason from the marker; the API SHALL answer 409 (`423` is not used, so clients keep one code for a state refusal). Reading, downloading and previewing SHALL stay allowed. When the owning object of a node inside the register folder tree cannot be resolved, the write SHALL be refused. +Every write to a file attached to a frozen or archived object SHALL be refused: creating, replacing, updating, renaming, moving out or in, deleting, locking and unlocking, through the OpenRegister file API, through the object-folder endpoints (`POST/PUT/DELETE .../folder`, `POST .../folder/upload`) and through Nextcloud Files or WebDAV in the object's folder. The refusal SHALL use `ObjectStateWriteException` and SHALL name the state, the actor, the moment and the reason from the marker; the API SHALL answer 409 (`423` is not used, so clients keep one code for a state refusal). Reading, downloading and previewing SHALL stay allowed. When the owning object of a node inside the register folder tree cannot be resolved, the write SHALL be refused. #### Scenario: a withdrawn publication's attachment cannot be replaced - **GIVEN** a publication frozen on withdrawal by `functioneel-beheer` with reason "ingetrokken, besluit 2026-114" @@ -194,6 +194,14 @@ Every write to a file attached to a frozen or archived object SHALL be refused: - **THEN** the upload is refused with a message naming the freeze, who froze it, when and the reason - **AND** the attachment can still be downloaded +#### Scenario: the object-folder endpoints refuse a frozen object + + +- **GIVEN** a frozen or archived object and a person who may update it +- **WHEN** they create a folder, upload, rename or delete in its object folder through the folder endpoints +- **THEN** the answer is 409 with `state`, `by`, `at` and `reason`, and nothing in the folder is touched +- **AND** a person who may not update it still gets 403, and listing the folder still works + #### Scenario: a WebDAV write into the frozen folder is aborted diff --git a/openspec/changes/object-tree-read/specs/referential-integrity/spec.md b/openspec/changes/object-tree-read/specs/referential-integrity/spec.md index 5f7988fbce..fcea42003d 100644 --- a/openspec/changes/object-tree-read/specs/referential-integrity/spec.md +++ b/openspec/changes/object-tree-read/specs/referential-integrity/spec.md @@ -27,6 +27,14 @@ parent SHALL answer with the root alone. - **THEN** each organisation appears once and the answer returns - @e2e exclude {cycle guard, covered by ObjectTreeServiceTest} +#### Scenario: a level wider than the node cap is not read in full + +- **GIVEN** a holding with fifty subsidiaries and an instance node cap of ten +- **WHEN** a client reads the holding's tree +- **THEN** the level is read with a row limit of at most one more than the cap still admits, never in full +- **AND** `truncated` is true with `truncatedBy` `cap` +- @e2e exclude {work bound, covered by ObjectTreeServiceTest} + #### Scenario: a depth below the tree's height is reported - **GIVEN** the holding of the first scenario @@ -55,3 +63,46 @@ SHALL be answered with 404 before anything is walked. - **WHEN** the user requests its tree - **THEN** the answer is 404 and no level is read - @e2e exclude {covered by ObjectRelationsControllerTreeTest} + +#### Scenario: a failing access check is an incomplete tree + +- **GIVEN** a holding with subsidiaries and an access check that cannot be established +- **WHEN** a client reads the holding's tree +- **THEN** no subsidiary is returned +- **AND** `truncated` is true with `truncatedBy` `unreadable`, not `null` +- @e2e exclude {fail-closed path, covered by ObjectTreeServiceTest} + +#### Scenario: the topmost ancestor does not name a hidden parent + +- **GIVEN** a subsidiary below a parent the caller may read, which is below a grandparent the caller may not +- **WHEN** the caller reads the subsidiary's ancestors +- **THEN** the parent is returned with `parent` null +- @e2e exclude {RBAC pruning, covered by ObjectTreeServiceTest} + +### Requirement: The tree carries only what the caller may read of an object's properties (REQ-OTR-003) + +The `fields` a client names SHALL be read from the object as every other read of it +renders it: a `writeOnly` property is never returned, a property whose `authorization.read` +the caller does not satisfy is never returned, and an encrypted property is returned +decrypted for a caller who may read it. This holds for the root and for every ancestor +and descendant, and `fields` is a projection of that result. + +#### Scenario: a write-only secret is not returned by naming it + +- **GIVEN** a schema property `secret` with `writeOnly: true` and a property the caller's groups may not read +- **WHEN** the caller reads the tree with both names in `fields` +- **THEN** neither value is returned on any node +- @e2e exclude {redaction seam, covered by ObjectTreeServiceTest} + +### Requirement: The tree read honours doelbinding (REQ-OTR-004) + +On a register or schema that asks for a declared purpose, the tree read (and the relation +graph and its CSV) SHALL refuse a request that names none, with the same 403 body the +object read answers, after the read check and before anything is walked. + +#### Scenario: a purpose-bound schema refuses an unbound tree read + +- **GIVEN** a schema that requires a declared purpose and a caller who may read the object +- **WHEN** the caller reads the tree without a purpose +- **THEN** the answer is 403 and no level is read +- @e2e exclude {covered by ObjectRelationsControllerTreeTest} diff --git a/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md b/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md index 64ad7e5962..1487874fc6 100644 --- a/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md +++ b/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md @@ -32,6 +32,17 @@ refused (404), even when the flow and node id otherwise resolve. caller keys win, authored keys the caller did not send stay, and the published version pin does not drop the caller's config +#### Scenario: A caller key the node does not declare is dropped + +- **GIVEN** a published node that declares the config keys `templateId` and + `folder` +- **WHEN** the caller runs it directly with + `config: {templateId: "picked", webhookUrl: "https://example.invalid/"}` +- **THEN** the node executes with `{templateId: "picked", folder: "Besluiten"}`: + only keys the node declares (`configKeys()`, else the keys of its + `configForm()`) are taken from the caller, and a node that declares neither + takes nothing from the caller + ### Requirement: Direct invocation is authorized against the subject object The endpoint MUST NOT authorize solely on the flow named-rights matrix diff --git a/openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md b/openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md index 1f598f4373..366b13cd09 100644 --- a/openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md +++ b/openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md @@ -8,7 +8,7 @@ An administrator re-imports an app's real register configuration on demand, and ### Requirement: Re-import imports the app's real register configuration (REQ-RDA-010) -`occ openregister:descriptors:list --app= --import=` SHALL import the app's real register configuration, forced. That is `lib/Settings/_register.json` with its `lib/Settings/register.d/*.json` fragments, the same configuration an install or upgrade imports. It SHALL run through the app's own SettingsService when that has a forced entry point. Otherwise OpenRegister SHALL import those files itself and SHALL say the app's own post-import steps did not run. A mock descriptor SHALL NOT be imported by `--import` alone. +`occ openregister:descriptors:list --app= --import=` SHALL import the app's real register configuration, forced. That is `lib/Settings/_register.json` with its `lib/Settings/register.d/*.json` fragments, the same configuration an install or upgrade imports. It SHALL run through the app's own SettingsService when that has a forced entry point. Otherwise OpenRegister SHALL import those files itself and SHALL say the app's own post-import steps did not run. A mock descriptor SHALL NOT be imported by `--import` alone. Of OpenRegister's own descriptors, the ones whose install and upgrade steps import under a configuration id of their own (`openregister.decision-tables`, `openregister.dismissed-pair`, `openregister.edepot-transfers`, `openregister.merge-operation`, `openregister.trust-configuration`, `openregister.vocabulary`, `openregister.dsar-cases`, `openregister.dsar-policy-packs`) SHALL be re-imported under that same id, read from the one map the install steps read, so the re-import moves no version gate another descriptor shares under `openregister`; the others import under `openregister`. The result SHALL carry the import job and the configuration id it ran under when the import recorded one. #### Scenario: The app ships both a mock and a real descriptor for the same register @@ -17,6 +17,12 @@ An administrator re-imports an app's real register configuration on demand, and - **THEN** the configuration of version 0.97.0 is imported under configuration id `portaliq` - **AND** no demo object from the mock is imported +#### Scenario: A core descriptor keeps its install-time configuration id + +- **GIVEN** OpenRegister ships `merge_operation_register.json`, which its repair step imports under `openregister.merge-operation` +- **WHEN** an administrator re-imports register `merge-operation` of `openregister` +- **THEN** the import runs under configuration id `openregister.merge-operation`, not `openregister` + #### Scenario: Fragments are part of the configuration - **GIVEN** an app ships `register.d/10-extra.json` adding a schema @@ -71,7 +77,7 @@ An administrator re-imports an app's real register configuration on demand, and ### Requirement: The re-import reports what changed (REQ-RDA-012) -The real re-import SHALL report registers and schemas as created, updated and unchanged, measured from the database before and after the import. It SHALL report how many authorization blocks it took from the file over a change made on the instance, and name each. +The real re-import SHALL report registers and schemas as created, updated and unchanged, measured from the database before and after the import. It SHALL report how many authorization blocks it took from the file over a change made on the instance, and name each. The administrator panel (Register descriptors) SHALL ask for confirmation before it runs a re-import, saying that the app's schema authorization is taken from the file over any local change and that the app's other registers may be re-imported with it, and SHALL show the response's count and every replaced block (schema and path) in its outcome note, as a warning and not as a plain success when any block was replaced. #### Scenario: Counts come from the database @@ -84,3 +90,4 @@ The real re-import SHALL report registers and schemas as created, updated and un - **WHEN** the re-import takes `portalAccount`'s authorization from the file over a local change - **THEN** the output counts 1 replaced block and names `portalAccount: authorization` +- **AND** the panel's outcome note shows the count and `portalAccount: authorization` as a warning, after the administrator confirmed the re-import diff --git a/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md b/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md index ee1b6279b5..1dd4a44349 100644 --- a/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md +++ b/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md @@ -32,7 +32,7 @@ When ownership is `decider` for the relation's schema (from `x-openregister-revi ### Requirement: A schema can hide one reviewer's decisions from another (REQ-ROD-002) -When `hideOthers` is on, every endpoint that returns entity relations (`gdprEntities#index`, `gdprEntities#show`, the file entity listings and the review leaf's reads) SHALL return a relation decided by someone other than the caller with `decision: "hidden"`, without its decision record (`decidedBy`, `decidedAt`, `note`) and without `bases`, unless the caller is a supervisor or an administrator. The relation SHALL still be listed with its type and position. +When `hideOthers` is on, every endpoint that returns entity relations (`gdprEntities#index`, `gdprEntities#show`, the file entity listings and the review leaf's reads) SHALL return a relation decided by someone other than the caller with `decision: "hidden"`, without its decision record (`decidedBy`, `decidedAt`, `note`), without `bases` and `skipAnonymization`, and without `anonymized` and `anonymizedValue` (an anonymise pass writes both from the verdict, so they would show it), unless the caller is a supervisor or an administrator. The relation SHALL still be listed with its type and position. #### Scenario: a blind second review - **GIVEN** `hideOthers` on and ten occurrences, four decided by `j.devries` diff --git a/openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md b/openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md index 68918778b2..693e78b428 100644 --- a/openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md +++ b/openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md @@ -4,7 +4,7 @@ ### Requirement: A search hit's deep link can name the object's own properties -When the unified search formats a hit, the object data handed to the deep-link registry MUST include the object's own declared scalar properties, URL-encoded, so a template can name one (for example `/apps/dossiq/cases/{case}`). `@self` metadata and the resolved `uuid`, `register` and `schema` MUST win over an own property of the same name. Keys starting with `_` (attached by the pipeline, not declared by the schema) and non-scalar values MUST NOT be passed. When the resolved link still contains an unfilled `{placeholder}`, the hit MUST link to OpenRegister's own object page instead. +When the unified search formats a hit, the object data handed to the deep-link registry MUST include the object's own declared scalar properties, URL-encoded, so a template can name one (for example `/apps/dossiq/cases/{case}`). `@self` metadata and the resolved `uuid`, `register` and `schema` MUST win over an own property of the same name. Keys starting with `_` (attached by the pipeline, not declared by the schema), non-scalar values, booleans, and empty or blank strings MUST NOT be passed, so a property that holds nothing leaves its placeholder unfilled instead of being filled with nothing. When the resolved link still contains an unfilled `{placeholder}`, the hit MUST link to OpenRegister's own object page instead. #### Scenario: A case-owned record opens its case - **GIVEN** an app registered `/apps/dossiq/cases/{case}` for schema `caseDocument` @@ -22,6 +22,6 @@ When the unified search formats a hit, the object data handed to the deep-link r - **THEN** `{case}` resolves to `..%2Fsettings` #### Scenario: An unfilled placeholder falls back -- **GIVEN** the `{case}` template and an object with no `case` +- **GIVEN** the `{case}` template and an object with no `case`, or one whose `case` is `""` or blank - **WHEN** the hit is formatted - **THEN** the hit's URL is OpenRegister's `openregister.objects.show` route for the object diff --git a/openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md b/openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md index 4dc5520ebb..ff63bc1ac2 100644 --- a/openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md +++ b/openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md @@ -12,7 +12,19 @@ leave the object alone and count it as unchanged. Objects the configuration does not list SHALL NOT be read or written. The update SHALL store the shipped version on the object, so the next import -reads the seed as current. Any other update SHALL bump the patch version. +reads the seed as current. Any other update SHALL bump the patch version. A +version carried in `@self.version` SHALL count only for a save that runs +without RBAC (the import, an administrator, a system caller): a caller judged +by RBAC SHALL NOT set an object's version, so the seed channel cannot be +steered from the object API. + +The import SHALL compare the shipped version with the version it LAST SHIPPED +for that seed, which it remembers apart from the object (per app id, never on +the object), not with the object's own version: every later save of the object +bumps its patch, so an edited seed would otherwise read as already current and +a changed shipped seed would never arrive. A seed stored before the import +remembered anything is compared with its stored version once, and remembered +at the version the file then ships. #### Scenario: An upgraded instance receives the shipped case type @@ -21,6 +33,12 @@ reads the seed as current. Any other update SHALL bump the patch version. - **THEN** the stored object is updated to the shipped values - **AND** its `@self.version` is `1.4.7` +#### Scenario: A seed edited on the instance still receives the next shipped version + +- **GIVEN** a seed shipped at `1.4.7`, then saved once on the instance (its own version is now `1.4.8`) +- **WHEN** the configuration ships it at `1.4.8` +- **THEN** the stored object is updated to the shipped values, and `1.4.8` is remembered as the shipped version + #### Scenario: A seed at the shipped version is left alone - **GIVEN** an instance holding the seed at the shipped version diff --git a/openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md b/openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md index d24a34a541..8855234e32 100644 --- a/openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md +++ b/openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md @@ -28,6 +28,14 @@ Reading the right MUST fail closed: an organisation, register or schema that cannot be loaded, a value that is not a list, an entry of neither shape, and the wildcard `*` admit nobody. +Every grant, every revoke and every change of the owning group on a record MUST +write an audit-trail entry on the hash-chained trail, of its own typed action +(`grant_added`, `grant_revoked`, `owner_group_changed`): who acted (the caller), +the record, the principal and permissions of the grant (or the owning group +before and after), and whether the caller was admitted as owner or as holder of +the right. A refused request writes none. Failing to write the entry MUST be +logged at error level and MUST NOT undo the change that was stored. + #### Scenario: A service account writes a grant on a record it does not own - **GIVEN** a private record owned by a handler, of schema `private-note` in register `records` - **AND** the record's organisation lists `{"group": "records-service", "register": "records", "schemas": ["private-note"]}` under `grant_manage` @@ -44,6 +52,10 @@ wildcard `*` admit nobody. - **WHEN** the caller lists, adds or revokes a grant through the sharing API - **THEN** each answer is HTTP 403 and nothing is written +#### Scenario: A grant and its revoke are on the trail +- **GIVEN** the holder of the right adds a read grant for user `reviewer` and then revokes it +- **THEN** a `grant_added` and a `grant_revoked` entry name the record, `reviewer` and `admittedAs: special-right` + #### Scenario: A holder cannot let itself in - **WHEN** a holder adds a grant for its own account, or for a group it is in, or names a group it is in as the owning group - **THEN** the request is refused and nothing is written @@ -77,6 +89,11 @@ kept per group: giving it again replaces the group's entry. - **WHEN** the same member sends an authorization block without `grant_manage` while the stored list is not empty - **THEN** the answer is HTTP 403 and the stored list is unchanged +#### Scenario: A change of the list is on the trail +- **WHEN** an administrator or the owner changes the list under `grant_manage` +- **THEN** a `settings.updated` entry names the organisation, the list before and the list after, and the acting user +- **AND** a save that leaves the list as it was writes none + #### Scenario: An app narrows what its service group holds - **GIVEN** an organisation where group `records-service` holds the right on schema `old` - **WHEN** the app gives the group the right on schemas `private-note` and `review` diff --git a/openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md b/openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md index bcac30147c..acd067e92e 100644 --- a/openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md +++ b/openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md @@ -13,7 +13,7 @@ OpenRegister MUST ship a core register `decision-tables` with a schema `decision ### Requirement: A stored table the evaluator cannot run is refused at save (REQ-SDT-02) -Saving a `decision-table` object MUST be refused with error code `decision-table-invalid` when `DecisionTableValidator` reports a problem, or when another table already carries the same `key`. Objects of other schemas MUST NOT be affected. +Saving a `decision-table` object MUST be refused with error code `decision-table-invalid` when `DecisionTableValidator` reports a problem, or when another table already carries the same `key`, or when the check for another table with the same `key` cannot run (the save is refused, never waved through), or when whether the object is a decision table cannot be read (a schema or register that cannot be read, as opposed to one that does not exist, which leaves the object alone). Objects of other schemas MUST NOT be affected. #### Scenario: A rule with too few input entries - **GIVEN** a table whose rule has fewer input entries than the table has inputs @@ -25,9 +25,14 @@ Saving a `decision-table` object MUST be refused with error code `decision-table - **WHEN** a second table with key `parcel-carrier` is saved - **THEN** the save MUST be refused naming the first table +#### Scenario: A key that cannot be checked +- **GIVEN** a search for tables with the key that fails +- **WHEN** a table is saved +- **THEN** the save MUST be refused with `decision-table-invalid`, saying the key could not be checked + ### Requirement: A table is resolved by key or id (REQ-SDT-03) -`DecisionTableStore::find()` MUST return the table whose `key` equals the reference, else the table whose uuid equals it. A reference that matches no table MUST raise an error, never return an empty table. A stored table the evaluator cannot run MUST raise an error on read. `evaluate()` MUST run the resolved table through `DecisionTableEvaluator`. +`DecisionTableStore::find()` MUST return the table whose `key` equals the reference, else the table whose uuid equals it. A reference that matches no table MUST raise an error, never return an empty table. When more than one table carries the key (a row written around the save guard, an import), `find()` MUST raise an error naming the tables instead of picking one. A stored table the evaluator cannot run MUST raise an error on read. `evaluate()` MUST run the resolved table through `DecisionTableEvaluator`. #### Scenario: Unknown reference - **WHEN** `find('nope')` is called and no table carries `nope` diff --git a/openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md b/openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md index 564ccfb801..952aed09ad 100644 --- a/openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md +++ b/openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md @@ -4,7 +4,7 @@ ### Requirement: A property whose name starts with an underscore is read as a property (REQ-MTR-001) -A magic-table column whose name starts with `_` SHALL be read as the schema property it holds when the schema declares that property, unless ObjectEntity has an attribute of the same name, in which case it SHALL be read as that metadata. Every reader of a magic-table row SHALL apply this rule, and the bulk writer SHALL write such a property. A metadata column ObjectEntity has no attribute for SHALL be skipped without failing the row, and SHALL be logged at debug level at most once per field per process. +A magic-table column whose name starts with `_` SHALL be read as the schema property it holds when the schema declares that property, unless ObjectEntity has an attribute of the same name, in which case it SHALL be read as that metadata. Every reader of a magic-table row SHALL apply this rule, and the bulk writer SHALL write such a property. A writer, bulk or single-object, SHALL NOT write a declared property into a metadata column: when the column a property sanitises to (`_owner`, `_Owner`, `__owner_`, `_authorization`, `_status`, ...) is one ObjectEntity has an attribute for, the property value is not written, so a client value can never replace the value the server stamped there. A metadata column ObjectEntity has no attribute for SHALL be skipped without failing the row, and SHALL be logged at debug level at most once per field per process. #### Scenario: shillinq's `_meta` property survives a read - **GIVEN** a schema with properties `code` and `_meta` (an object), and a stored row with `_meta` = `{"source":"seeded"}` @@ -17,3 +17,9 @@ A magic-table column whose name starts with `_` SHALL be read as the schema prop - **WHEN** the row is converted - **THEN** the entity SHALL be returned with its uuid and `schemaVersion` `1.2.0`, and `_meta` SHALL be skipped - @e2e exclude {row conversion has no screen of its own; covered by MagicStatisticsHandlerTest} + +#### Scenario: a property named like a metadata column does not overwrite it +- **GIVEN** a schema declaring properties `_owner`, `_authorization` and `_status`, and an object whose payload carries a value for each +- **WHEN** the object is prepared for a bulk or single-object write +- **THEN** the `_owner` column SHALL hold the owner the server stamped, and no client value SHALL reach `_authorization` or `_status` +- @e2e exclude {write preparation has no screen of its own; covered by MagicBulkHandlerRawAppendTest and MagicMetadataColumnTest} diff --git a/openspec/specs/ai-mcp/spec.md b/openspec/specs/ai-mcp/spec.md index c5d5934445..10c9f18ebb 100644 --- a/openspec/specs/ai-mcp/spec.md +++ b/openspec/specs/ai-mcp/spec.md @@ -699,7 +699,7 @@ invocation behavior. ### Requirement: REQ-ATTR-007 — A curated attribute tool forwards free-form annotations -`#[McpTool]` MUST accept an optional `annotations` map whose keys are non-empty strings and whose values are scalars. `AttributeToolScanner` MUST reject a malformed map at scan time (logged, the offending tool skipped, sibling tools unaffected) and MUST forward a non-empty map into the descriptor under `annotations`; an empty map MUST NOT be forwarded. `AttributeToolProvider::getTools()` and `McpProviderBridge::getFunctions()` MUST carry the key through unchanged, so a consumer reads the same map on the JSON-RPC surface and on `ToolRegistryFacade::listTools()`. OpenRegister MUST NOT interpret any mark: a mark is a claim, and grant resolution, reach and RBAC MUST NOT change because of one. +`#[McpTool]` MUST accept an optional `annotations` map whose keys are non-empty strings and whose values are scalars, and whose keys MUST NOT be one of the five tool-hint keys the MCP specification reserves in a descriptor's `annotations` object (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `title`; compared case-insensitively), because the map is forwarded to external clients under that key and a client could trust a hint OpenRegister's own checks contradict. `AttributeToolScanner` MUST reject a malformed map at scan time (logged, the offending tool skipped, sibling tools unaffected) and MUST forward a non-empty map into the descriptor under `annotations`; an empty map MUST NOT be forwarded. `AttributeToolProvider::getTools()` and `McpProviderBridge::getFunctions()` MUST carry the key through unchanged, so a consumer reads the same map on the JSON-RPC surface and on `ToolRegistryFacade::listTools()`. OpenRegister MUST NOT interpret any mark: a mark is a claim, and grant resolution, reach and RBAC MUST NOT change because of one. #### Scenario: A declared mark appears on both surfaces - **GIVEN** app `dossiq` exposes `#[McpTool(scope: 'create', action: 'create', annotations: ['citizenIntake' => true])] fileCase(string $title)` @@ -713,6 +713,13 @@ invocation behavior. - **THEN** the descriptor MUST NOT carry an `annotations` key @e2e exclude Descriptor-shape assertion with no UI surface; asserted by AttributeToolScannerTest. +#### Scenario: A reserved MCP hint key is rejected at scan time +- **GIVEN** a method `#[McpTool(scope: 'update', annotations: ['readOnlyHint' => true])]` +- **WHEN** `AttributeToolScanner` scans the declaring class +- **THEN** no tool MUST be registered for that method +- **AND** a warning MUST be logged naming `annotations` and the offending key +@e2e exclude Malformed-attribute path with no UI surface; asserted by AttributeToolScannerTest. + #### Scenario: A malformed map is rejected at scan time - **GIVEN** a method `#[McpTool(annotations: ['citizenIntake' => ['nested' => true]])]` - **WHEN** `AttributeToolScanner` scans the declaring class 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/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/external-recipient-opt-out/spec.md b/openspec/specs/external-recipient-opt-out/spec.md index be422dc136..47dae82f98 100644 --- a/openspec/specs/external-recipient-opt-out/spec.md +++ b/openspec/specs/external-recipient-opt-out/spec.md @@ -125,7 +125,7 @@ A `parties` notification MUST send the rule's resolved `message` as the mail bod ### Requirement: An email recipient reads its address through a reference (REQ-ERO-007) -An `x-openregister-notifications` rule MAY address `{kind: "email", field: ""}`. The path's last segment names the address; every segment before it names a reference that OpenRegister MUST follow to the referenced object, at most three references deep. OpenRegister MUST read the referenced object as the system, so the rule reaches the address even when the person whose save fired the rule may not read that object, and MUST refuse a referenced object that belongs to another organisation than the triggering object (outcome `refused-other-tenant`). A value that is not an e-mail address MUST NOT be mailed (`not-an-address`); a missing reference or object MUST be named (`no-reference`, `reference-not-found`). A resolved address MUST be put to integriq like a party address (REQ-ERO-003) and carry the unsubscribe link (REQ-ERO-004). Every entry MUST leave a history row with its outcome, recorded against the path and not the address. The validator MUST refuse an `email` entry without a `field` (`notification-recipient-email-field-missing`), whose first segment is not a declared property (`notification-recipient-field-unknown`), that follows more than three references (`notification-recipient-email-path-too-deep`), or on a rule without the `email` channel (`notification-recipient-email-needs-email-channel`). +An `x-openregister-notifications` rule MAY address `{kind: "email", field: ""}`. The path's last segment names the address; every segment before it names a reference that OpenRegister MUST follow to the referenced object, at most three references deep. OpenRegister MUST read the referenced object as the system, so the rule reaches the address even when the person whose save fired the rule may not read that object, and MUST refuse a referenced object that belongs to another organisation than the triggering object (outcome `refused-other-tenant`). The referenced object MUST also be an object of the schema the reference property's `$ref` names, read from the schema of the object the hop starts at; a reference to an object of another schema, and a reference property that declares no `$ref` (so names no schema to check against), MUST be refused with `reference-not-found` and nothing sent. A value that is not an e-mail address MUST NOT be mailed (`not-an-address`); a missing reference or object MUST be named (`no-reference`, `reference-not-found`). A resolved address MUST be put to integriq like a party address (REQ-ERO-003) and carry the unsubscribe link (REQ-ERO-004). Every entry MUST leave a history row with its outcome, recorded against the path and not the address. The validator MUST refuse an `email` entry without a `field` (`notification-recipient-email-field-missing`), whose first segment is not a declared property (`notification-recipient-field-unknown`), that follows more than three references (`notification-recipient-email-path-too-deep`), or on a rule without the `email` channel (`notification-recipient-email-needs-email-channel`). #### Scenario: A supplier message mails the supplier's contact address @@ -142,6 +142,14 @@ An `x-openregister-notifications` rule MAY address `{kind: "email", field: "`. These verbs SHALL NOT be reachable over HTTP. The user verbs SHALL keep refusing a missing identity. +A flow run realised for such a plan (a stage or process task bound to a flow) +SHALL be queued without the `system:` label as its user, because that label +names no account and a run that carried it would refuse every scoped step; the +flow's own attribution decides who the run acts as. + #### Scenario: An app's listener creates a plan - **GIVEN** an object without a plan diff --git a/openspec/specs/integration-person-lookup/spec.md b/openspec/specs/integration-person-lookup/spec.md index 119fb55abb..b1f571ed2a 100644 --- a/openspec/specs/integration-person-lookup/spec.md +++ b/openspec/specs/integration-person-lookup/spec.md @@ -164,7 +164,7 @@ that both transports are configured on the OpenConnector source. The provider SHALL ask only when the `brp-haalcentraal` source carries `configuration.residentCount` set to the boolean `true`. Otherwise it SHALL answer `{ unavailable: true, cause: 'resident-count-not-enabled' }` without calling the registry. -The call SHALL reach the connector with `withholdBodies`, so the call log keeps no request or response body. A connector that cannot withhold bodies SHALL NOT be called, and the answer is `{ unavailable: true, cause: 'upstream-service-down' }`. +The call SHALL reach the connector with `withholdBodies`, so the call log keeps no request or response body. A connector that cannot withhold bodies SHALL NOT be called, and the answer is `{ unavailable: true, cause: 'connector-cannot-withhold-bodies' }`. The answer SHALL never hold a name, a BSN, a person object or the address object id, and the provider SHALL never log the address object id. A malformed id SHALL answer `{ unavailable: true, cause: 'invalid-identifier' }` without a call. A missing or unreachable source SHALL degrade to `{ unavailable: true, cause }` as `lookupByBsn` does. @@ -194,7 +194,7 @@ The answer SHALL never hold a name, a BSN, a person object or the address object ### Requirement: a call may be recorded without its bodies -`ExternalIntegrationRouter` SHALL honour a `withholdBodies: true` call option by passing `withholdBodies: true` to the connector's `CallService::call`, and SHALL never pass that option on as a request option. When the connector's `call` has no `withholdBodies` parameter, the router SHALL NOT call it and SHALL fail with cause `upstream-service-down`. `sourceConfiguration(provider)` SHALL answer the provider's source `configuration` array and fail like `call()` when the connector or the source is missing. +`ExternalIntegrationRouter` SHALL honour a `withholdBodies: true` call option by passing `withholdBodies: true` to the connector's `CallService::call`, and SHALL never pass that option on as a request option. When the connector's `call` has no `withholdBodies` parameter, the router SHALL NOT call it and SHALL fail with cause `connector-cannot-withhold-bodies`, logged below error level (an older connector is a version gap, not an outage). `sourceConfiguration(provider)` SHALL answer the provider's source `configuration` array and fail like `call()` when the connector or the source is missing. #### Scenario: the connector records the call without bodies @@ -207,5 +207,5 @@ The answer SHALL never hold a name, a BSN, a person object or the address object - GIVEN a CallService whose `call` has no `withholdBodies` parameter - WHEN a provider calls with `withholdBodies: true` -- THEN the CallService is not called and the router fails with `upstream-service-down` +- THEN the CallService is not called and the router fails with `connector-cannot-withhold-bodies`, without an error log line - @e2e exclude Backend refusal path, verified by PHPUnit. diff --git a/openspec/specs/migration-mapping-packs/spec.md b/openspec/specs/migration-mapping-packs/spec.md index 4aadedd280..da0347ef0b 100644 --- a/openspec/specs/migration-mapping-packs/spec.md +++ b/openspec/specs/migration-mapping-packs/spec.md @@ -4,7 +4,7 @@ TBD - created by archiving change migration-mapping-packs. Update Purpose after archive. ## Requirements ### Requirement: The system MUST validate migration pack definitions structurally before storing them -A migration pack definition SHALL be a JSON document with a slug `id` (lowercase letters/digits/hyphens, unique per instance), a non-empty `name`, a `sourceFormat` from `csv|json|excel`, a strict-semver `version`, a non-empty `fieldMappings` array (each entry with non-empty `source` and `target` strings, optional boolean `required`, optional `transform`), an `idStrategy` (`{type: "generate"}` or `{type: "sourceField", field: ""}`), and optional `defaults` (object) and `skipRows` (positive integers). Transform blocks SHALL declare a `type` from `trim|date|bool-map|concat|lookup|const` and carry that type's required fields (`map` for bool-map/lookup, `fields` for concat, `value` for const). Structurally invalid definitions SHALL be rejected at create/update/import time with HTTP 422, never stored and never deferred to import time. +A migration pack definition SHALL be a JSON document with a slug `id` (lowercase letters/digits/hyphens, unique per instance), a non-empty `name`, a `sourceFormat` from `csv|json|excel`, a strict-semver `version`, a non-empty `fieldMappings` array (each entry with non-empty `source` and `target` strings, optional boolean `required`, optional `transform`), an `idStrategy` (`{type: "generate"}` or `{type: "sourceField", field: ""}`), and optional `defaults` (object) and `skipRows` (positive integers). Transform blocks SHALL declare a `type` from `trim|date|bool-map|concat|lookup|const` and carry that type's required fields (`map` for bool-map/lookup, `fields` for concat, `value` for const). Structurally invalid definitions SHALL be rejected at create/update/import time with HTTP 422, never stored and never deferred to import time. Every finding the validator returns, and every row error the mapping engine returns (shown in the pack editor's save error and preview, and in an import's error file), SHALL be translated into the user's language through the app's translations; the property names and values inside a message stay as written. #### Scenario: Valid pack definition is accepted - **GIVEN** an admin submits a pack with a slug id, name, `sourceFormat: "csv"`, `version: "1.0.0"`, one field mapping with source and target, and `idStrategy: {type: "generate"}` @@ -78,7 +78,7 @@ The system SHALL expose `/api/migration-packs` REST routes: `index`/`show`/`expo #### Scenario: Pack JSON export/import round-trips between instances - **GIVEN** a pack exported as `zgw-zaken-json.json` from one instance - **WHEN** an admin uploads that file to `POST /api/migration-packs/import` on another instance -- **THEN** an identical pack is created there; re-importing an updated file with the same `id` updates the existing row instead of erroring +- **THEN** an identical pack is created there (HTTP 201); re-importing an updated file with the same `id` updates the existing row instead of erroring and answers HTTP 200, so the page can say the stored pack was replaced instead of reporting it as newly imported #### Scenario: Anonymous callers are rejected everywhere - **WHEN** an unauthenticated request hits any `/api/migration-packs` route 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/openspec/specs/referential-integrity/spec.md b/openspec/specs/referential-integrity/spec.md index bd4076669f..fcdf5cc6d6 100644 --- a/openspec/specs/referential-integrity/spec.md +++ b/openspec/specs/referential-integrity/spec.md @@ -415,7 +415,7 @@ Referential integrity operations MUST bypass RBAC and multi-tenancy filters when - **AND** only the 3 accessible orders MUST be returned ### Requirement 16: A reference that cites a row by a key property MUST be able to refuse its delete -A schema property whose values cite rows of another schema by a key property (a code, not a uuid) MUST be able to declare `referenceSchema` (the cited schema, on the property or on `items`), `referenceKey` (the cited schema's property, e.g. `code`) and `onDelete: RESTRICT`. Deleting a cited row MUST then be refused with the citing rows as blockers, exactly as for a uuid reference. `referenceSchema` MUST NOT engage the `$ref` relation behaviour (rendering, `_extend`, uuid validation). A key reference MUST be honoured only with `RESTRICT`: saving a schema that declares `referenceKey` with any other action MUST be refused, and such a relation that reaches the relation index anyway MUST be left out with a warning. `onDelete` MUST be accepted on a property with `referenceSchema` as it is on one with `$ref`. The check MUST fail closed: when the citing rows cannot be read, the delete MUST be refused with a blocker whose `reason` is `unverifiable`; only a citing table that does not exist yet MUST count as "nothing cites it". A row without a value for the key MUST be deletable. This is the generic reference guard (capability C25 of hydra's `procedures-are-configuration`), so an app needs no delete listener of its own. +A schema property whose values cite rows of another schema by a key property (a code, not a uuid) MUST be able to declare `referenceSchema` (the cited schema, on the property or on `items`), `referenceKey` (the cited schema's property, e.g. `code`) and `onDelete: RESTRICT`. Deleting a cited row MUST then be refused with the citing rows as blockers, exactly as for a uuid reference. `referenceSchema` MUST NOT engage the `$ref` relation behaviour (rendering, `_extend`, uuid validation). A key reference MUST be honoured only with `RESTRICT`: saving a schema that declares `referenceKey` with any other action MUST be refused (a `referenceKey` that is empty or the literal `uuid` is a plain uuid reference, to which every action applies, and is read that way by the save and by the delete), and such a relation that reaches the relation index anyway MUST be left out with a warning. `onDelete` MUST be accepted on a property with `referenceSchema` as it is on one with `$ref`. The check MUST fail closed: when the citing rows cannot be read, the delete MUST be refused with a blocker whose `reason` is `unverifiable`; only a citing table that does not exist yet MUST count as "nothing cites it". A row without a value for the key MUST be deletable. This is the generic reference guard (capability C25 of hydra's `procedures-are-configuration`), so an app needs no delete listener of its own. #### Scenario: A cited code refuses the delete - **GIVEN** schema `note` with property `labels` = `{type: array, items: {type: string}, referenceSchema: label, referenceKey: code, onDelete: RESTRICT}` 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.", 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/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 --> @@ -150,6 +165,7 @@ import { generateUrl } from '@nextcloud/router' import { NcButton, NcLoadingIcon } from '@nextcloud/vue' import Download from 'vue-material-design-icons/Download.vue' import SettingsSection from '../../../components/shared/SettingsSection.vue' +import ReimportDescriptorDialog from '../../../dialogs/settings/ReimportDescriptorDialog.vue' /** * Register descriptors section. @@ -180,6 +196,7 @@ export default { Download, NcButton, NcLoadingIcon, + ReimportDescriptorDialog, SettingsSection, }, @@ -193,9 +210,29 @@ export default { error: '', busy: '', outcome: null, + confirming: null, } }, + computed: { + /** + * The colour class of the outcome note: a re-import that replaced + * authorization is a warning even though it succeeded. + * + * @return {string} The modifier class. + * + * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md + */ + outcomeClass() { + if (!this.outcome?.ok) { + return 'descriptors__outcome--bad' + } + return this.outcome.replaced.length > 0 + ? 'descriptors__outcome--warning' + : 'descriptors__outcome--good' + }, + }, + mounted() { this.load() }, @@ -296,35 +333,85 @@ export default { } }, + /** + * Ask before a re-import runs: it takes the file's authorization over + * a local change, and may re-import the app's other registers too. + * + * @param {object} row The row whose descriptor to re-import. + * @return {void} + * + * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md + */ + askToReimport(row) { + this.confirming = row + }, + + /** + * Close the confirmation without importing. + * + * @return {void} + * + * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md + */ + cancelReimport() { + this.confirming = null + }, + + /** + * The administrator confirmed: run the re-import of the asked row. + * + * @return {Promise} Resolves once the outcome is rendered. + * + * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md + */ + async confirmReimport() { + const row = this.confirming + this.confirming = null + if (row) { + await this.reimport(row) + } + }, + /** * Force a re-import and SHOW the outcome — a button that reports nothing - * is indistinguishable from one that did nothing. + * is indistinguishable from one that did nothing. The authorization + * blocks the import took from the file over a local change are listed, + * schema and path, and make the outcome a warning. * * @param {object} row The row whose descriptor to re-import. * @return {Promise} Resolves once the outcome is rendered. * - * @spec openspec/changes/register-descriptor-admin/specs/register-descriptor-admin/spec.md + * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md */ async reimport(row) { this.busy = this.rowKey(row) this.outcome = null try { - await axios.post( + const response = await axios.post( generateUrl( `/apps/openregister/api/register-descriptors/${row.appId}/${row.slug}/import`, ), ) - this.outcome = { - ok: true, - message: this.t( + const replaced = Array.isArray(response?.data?.replaced) + ? response.data.replaced + : [] + const count = response?.data?.counts?.authorizationReplaced ?? replaced.length + let message = this.t( + 'openregister', + '{register} was imported from {app}.', + { + register: row.title, + app: row.appId, + }, + ) + if (count > 0 || replaced.length > 0) { + message += ' ' + this.t( 'openregister', - '{register} was imported from {app}.', - { - register: row.title, - app: row.appId, - }, - ), + 'Authorization taken from the file over a local change: {count}.', + { count: Math.max(count, replaced.length) }, + ) } + this.outcome = { ok: true, message, replaced } // Re-read rather than patch the row locally: the inventory is // the authority on what is now present, and a locally-optimistic // row would report success the import may not have achieved. @@ -338,6 +425,8 @@ export default { ?? e?.response?.data?.error ?? e.message, }), + + replaced: [], } } finally { this.busy = '' @@ -420,10 +509,27 @@ export default { color: var(--color-error); } -.descriptors__hint--good { +.descriptors__outcome { + margin-top: 0.75rem; +} + +.descriptors__outcome--good { color: var(--color-success); } +.descriptors__outcome--warning { + color: var(--color-warning-text, var(--color-warning)); +} + +.descriptors__outcome--bad { + color: var(--color-error); +} + +.descriptors__replaced { + margin: 0.25rem 0 0 1.25rem; + list-style: disc; +} + .descriptors__sr { position: absolute; width: 1px; diff --git a/tests/Unit/BackgroundJob/CasePlanTimerRearmJobTest.php b/tests/Unit/BackgroundJob/CasePlanTimerRearmJobTest.php new file mode 100644 index 0000000000..0191f8889d --- /dev/null +++ b/tests/Unit/BackgroundJob/CasePlanTimerRearmJobTest.php @@ -0,0 +1,70 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\BackgroundJob; + +use DateTime; +use OCA\OpenRegister\BackgroundJob\CasePlanTimerRearmJob; +use OCA\OpenRegister\Service\Case\CasePlanTimerRearm; +use OCP\AppFramework\Utility\ITimeFactory; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionMethod; +use RuntimeException; + +/** + * @covers \OCA\OpenRegister\BackgroundJob\CasePlanTimerRearmJob + */ +class CasePlanTimerRearmJobTest extends TestCase { + + private CasePlanTimerRearm&MockObject $rearm; + + private LoggerInterface&MockObject $logger; + + private CasePlanTimerRearmJob $job; + + protected function setUp(): void { + parent::setUp(); + $this->rearm = $this->createMock(CasePlanTimerRearm::class); + $this->logger = $this->createMock(LoggerInterface::class); + $time = $this->createMock(ITimeFactory::class); + $time->method('getDateTime')->willReturn(new DateTime('2026-09-01 10:00:00')); + $this->job = new CasePlanTimerRearmJob(time: $time, rearm: $this->rearm, logger: $this->logger); + }//end setUp() + + private function tick(): void { + (new ReflectionMethod(CasePlanTimerRearmJob::class, 'run'))->invoke($this->job, null); + }//end tick() + + public function testRunsOnTheTimerSweepsInterval(): void { + self::assertSame(300, $this->job->getInterval()); + }//end testRunsOnTheTimerSweepsInterval() + + public function testAPassThatDidWorkIsLoggedAndAQuietOneIsNot(): void { + $this->rearm->expects(self::exactly(2))->method('run')->willReturnOnConsecutiveCalls( + ['armed' => 1, 'enforced' => 1, 'dropped' => 0, 'failed' => 2], + ['armed' => 0, 'enforced' => 0, 'dropped' => 0, 'failed' => 0] + ); + $this->logger->expects(self::once())->method('info') + ->with(self::stringContains('Armed 1 statutory timer(s), enforced 1 expired term(s), dropped 0 marker(s); 2 still failing'), self::anything()); + + $this->tick(); + $this->tick(); + }//end testAPassThatDidWorkIsLoggedAndAQuietOneIsNot() + + public function testAFailingPassIsLoggedNotThrown(): void { + $this->rearm->method('run')->willThrowException(new RuntimeException('db gone')); + $this->logger->expects(self::once())->method('error')->with(self::stringContains('db gone'), self::anything()); + + $this->tick(); + }//end testAFailingPassIsLoggedNotThrown() +}//end class diff --git a/tests/Unit/BackgroundJob/DraftExpiryJobTest.php b/tests/Unit/BackgroundJob/DraftExpiryJobTest.php index 4bcfb43a3a..4172886959 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; @@ -175,12 +179,16 @@ static function (ObjectEntity $object, string $action, array $context = [], ?str // One line for the schema that lost a draft, then the totals (decision 228). $logged = []; + $levels = []; $logger = $this->createMock(LoggerInterface::class); - $logger->method('info')->willReturnCallback( - static function (string $message, array $context = []) use (&$logged): void { - $logged[] = [$message, $context]; - } - ); + foreach (['info', 'warning'] as $level) { + $logger->method($level)->willReturnCallback( + static function (string $message, array $context = []) use (&$logged, &$levels, $level): void { + $logged[] = [$message, $context]; + $levels[] = $level; + } + ); + } $job = $this->build( class: $this->declaredJobClass(), @@ -206,6 +214,8 @@ class: $this->declaredJobClass(), // The second schema sets nothing, so it gets 30 days: looked at, nothing expired, no line of its own. $this->assertStringContainsString('swept drafts', $logged[1][0]); $this->assertSame(['schemas' => 2, 'expired' => 1, 'removed' => 1, 'refused' => 0, 'deferred' => 0], $logged[1][1]); + // A sweep that removed a draft is logged where the default log level (warning) shows it. + $this->assertSame(['warning', 'warning'], $levels); }//end testTheDeclaredJobRemovesAnExpiredDraft() /** @@ -245,4 +255,26 @@ class: $this->declaredJobClass(), $this->expectException(RuntimeException::class); $this->runJob(job: $job); }//end testAFailingSweepSurfaces() + + /** + * Drafts that were removed or refused are logged at warning, so the default log level shows them; a zero stays at info. + */ + public function testARefusalIsLoggedWhereTheDefaultLevelShowsIt(): void { + $expiry = $this->createMock(DraftExpiry::class); + $expiry->method('sweep')->willReturn( + [ + 'schemas' => 1, 'expired' => 2, 'removed' => 0, 'refused' => 2, 'deferred' => 0, + 'perSchema' => [['register' => 'service', 'schema' => 'ticket', 'lifetimeDays' => 7, 'expired' => 2, 'removed' => 0, 'refused' => 2, 'deferred' => 0]], + ] + ); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(2))->method('warning'); + $logger->expects($this->never())->method('info'); + + $job = $this->build( + class: $this->declaredJobClass(), + services: [ITimeFactory::class => $this->clock(), LoggerInterface::class => $logger, DraftExpiry::class => $expiry] + ); + $this->runJob(job: $job); + }//end testARefusalIsLoggedWhereTheDefaultLevelShowsIt() }//end class diff --git a/tests/Unit/Controller/AuditTrailObjectHistoryForReadersTest.php b/tests/Unit/Controller/AuditTrailObjectHistoryForReadersTest.php index aec1840bec..a2823fa6b3 100644 --- a/tests/Unit/Controller/AuditTrailObjectHistoryForReadersTest.php +++ b/tests/Unit/Controller/AuditTrailObjectHistoryForReadersTest.php @@ -220,6 +220,33 @@ public function testPropertiesHiddenFromTheReaderAreFilteredFromEveryEntry(): vo $this->assertStringNotContainsString('klokkenluider-bsn', (string)json_encode($result->getData())); } + public function testACascadeEntryDoesNotNameTheObjectWhoseDeleteCausedIt(): void { + $this->readableObject(); + $entry = new AuditTrail(); + $entry->setUuid('e2'); + $entry->setObjectUuid(self::OBJECT_UUID); + $entry->setAction('delete'); + $entry->setChanged([ + 'triggeredBy' => 'referential_integrity', + 'cascadeContext' => [ + 'triggerObject' => 'f3c2b1a0-0000-4000-8000-00000000beef', + 'triggerSchema' => 'verborgen-schema', + 'action_type' => 'referential_integrity.cascade_delete', + 'property' => 'interneAantekening', + ], + ]); + $this->auditTrailMapper->method('findAll')->willReturn([$entry]); + + $result = $this->controllerFor('behandelaar', false, ['behandelaars'])->objects('dossiq', 'zaak', self::OBJECT_UUID); + + $cascade = $result->getData()['results'][0]['changed']['cascadeContext']; + $this->assertNull($cascade['triggerObject']); + $this->assertNull($cascade['triggerSchema']); + $this->assertNull($cascade['property'], 'a property the reader may not see is not named either'); + $this->assertSame('referential_integrity.cascade_delete', $cascade['action_type']); + $this->assertStringNotContainsString('beef', (string)json_encode($result->getData())); + } + public function testAReaderAllowedThePropertySeesItInTheHistory(): void { $this->readableObject(); $this->auditTrailMapper->method('findAll')->willReturn([$this->updateEntry()]); 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); 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/Controller/MigrationPacksControllerTest.php b/tests/Unit/Controller/MigrationPacksControllerTest.php index 0cc7bdc87d..67db9c3905 100644 --- a/tests/Unit/Controller/MigrationPacksControllerTest.php +++ b/tests/Unit/Controller/MigrationPacksControllerTest.php @@ -29,6 +29,7 @@ namespace OCA\OpenRegister\Tests\Unit\Controller; +use OCA\OpenRegister\Tests\Unit\Service\MigrationPack\PassThroughL10nTrait; use DateTimeImmutable; use InvalidArgumentException; use OCA\OpenRegister\Controller\MigrationPacksController; @@ -63,6 +64,8 @@ use RuntimeException; class MigrationPacksControllerTest extends TestCase { + use PassThroughL10nTrait; + private MigrationPacksController $controller; private MigrationPackService&MockObject $service; private IUserSession&MockObject $userSession; @@ -133,7 +136,7 @@ private function controllerFor(IRequest $request, ?MigrationPackService $service $this->userSession, $this->groupManager, $this->logger, - new MigrationPackRequestReader(new PackDefinitionValidator(), $this->l10n), + new MigrationPackRequestReader(new PackDefinitionValidator($this->passThroughL10n()), $this->l10n), new MigrationPackErrorResponder($this->l10n, $this->logger) ); } @@ -854,7 +857,7 @@ public function testImportReplacingAStoredPackLogsTheActorSlugAndOutcome(): void ); $response = $this->importController($this->validDefinition())->import(); - $this->assertSame(201, $response->getStatus()); + $this->assertSame(200, $response->getStatus(), 'a replaced pack is not answered as Created'); } public function testImportOfANewSlugLogsThatItCreatedAPack(): void { @@ -970,13 +973,16 @@ public function testDestroyActsOnTheRouteIdAndLogsTheActorAndSlug(): void { public function testEveryTranslatedMessageHasADutchTranslation(): void { // PHP IL10N reads l10n/.json; a key missing there reaches a Dutch - // administrator in English. The controller's messages live in its two - // collaborators. + // administrator in English. The controller's messages live in its + // collaborators, and the pack editor shows the validator's and the + // mapping engine's findings too. $source = ''; foreach ([ 'lib/Controller/MigrationPacksController.php', 'lib/Service/MigrationPack/MigrationPackErrorResponder.php', 'lib/Service/MigrationPack/MigrationPackRequestReader.php', + 'lib/Service/MigrationPack/PackDefinitionValidator.php', + 'lib/Service/MigrationPack/MappingEngine.php', ] as $file) { $source .= file_get_contents(__DIR__ . '/../../../' . $file); } @@ -1040,8 +1046,8 @@ public function testWriteMethodsRequireCsrfAndAdmin(string $method): void { private function previewController(array $body): MigrationPacksController { $service = new MigrationPackService( $this->createMock(MigrationPackMapper::class), - new PackDefinitionValidator(), - new MappingEngine(), + new PackDefinitionValidator($this->passThroughL10n()), + new MappingEngine($this->passThroughL10n()), $this->createMock(IAppConfig::class) ); $request = new MergingRequestFake(['_route' => 'openregister.migrationPacks.preview'], $body, 'POST'); diff --git a/tests/Unit/Controller/ObjectDraftControllerTest.php b/tests/Unit/Controller/ObjectDraftControllerTest.php index d468215566..3da8cdfda2 100644 --- a/tests/Unit/Controller/ObjectDraftControllerTest.php +++ b/tests/Unit/Controller/ObjectDraftControllerTest.php @@ -60,6 +60,7 @@ class ObjectDraftControllerTest extends TestCase { private DraftActivation&MockObject $activation; + private IUserSession&MockObject $session; private LoggerInterface&MockObject $logger; @@ -99,6 +100,16 @@ protected function setUp(): void { $this->request = $this->createMock(IRequest::class); $this->objects = $this->createMock(ObjectService::class); + // The render boundary: takes the `writeOnly` property `secret` out of the entity, as the real one does for the schema that declares it. + $this->objects->method('renderEntity')->willReturnCallback( + static function (ObjectEntity $entity, ?array $_extend = [], ?int $depth = 0, ?array $filter = [], ?array $fields = [], ?array $unset = [], bool $_rbac = true): array { + $object = ($entity->getObject() ?? []); + unset($object['secret']); + $entity->setObject($object); + + return $entity->jsonSerialize(); + } + ); $this->objects->method('find')->willReturnCallback( function ( int|string $id, @@ -255,6 +266,28 @@ public function testTheOwnerGetsTheActivatedObject(): void { $this->assertSame(['id' => 'd1', 'register' => $this->register, 'schema' => $this->schema, 'rbac' => true, 'multitenancy' => true], $this->reads[0]); }//end testTheOwnerGetsTheActivatedObject() + /** + * The answer goes through the render boundary: a `writeOnly` property is never in the 200 body. + * + * Goes red without the redaction: the entity the save handed back carries the secret. + */ + public function testAWriteOnlyPropertyIsNotInTheActivatedObject(): void { + $this->signIn(uid: 'agent'); + $this->readableDraft(); + + $active = new ObjectEntity(); + $active->setUuid('d1'); + $active->setStatus('active'); + $active->setObject(['subject' => 'A', 'secret' => 'hunter2']); + $this->activationAnswers(answer: $active); + + $response = $this->controller->activate(register: 'service', schema: 'ticket', id: 'd1'); + + $this->assertSame(200, $response->getStatus()); + $this->assertStringNotContainsString('hunter2', (string)json_encode($response->getData())); + $this->assertSame('A', $response->getData()['subject'] ?? null); + }//end testAWriteOnlyPropertyIsNotInTheActivatedObject() + /** * A body without `object`, or with something that is no object, activates the draft as stored. */ diff --git a/tests/Unit/Controller/ObjectFolderControllerTest.php b/tests/Unit/Controller/ObjectFolderControllerTest.php index 1b07f3c5d2..c29ed782b3 100644 --- a/tests/Unit/Controller/ObjectFolderControllerTest.php +++ b/tests/Unit/Controller/ObjectFolderControllerTest.php @@ -41,6 +41,7 @@ use OCA\OpenRegister\Service\File\ObjectFolderResponder; use OCA\OpenRegister\Service\File\ObjectFolderUploads; use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\Object\FileWriteGuard; use OCA\OpenRegister\Service\Object\PermissionHandler; use OCA\OpenRegister\Service\ObjectService; use OCA\OpenRegister\Service\Rbac\DenyEnforcementMode; @@ -287,7 +288,8 @@ static function () use ($permissionHandler, $schema, $object): ObjectEntity { ), $browser, new ObjectFolderUploads(browser: $browser), - new ObjectFolderResponder(l10n: $l10n, logger: $logger) + new ObjectFolderResponder(l10n: $l10n, logger: $logger), + new FileWriteGuard() ); }//end controllerAs() @@ -598,4 +600,84 @@ public function testAnUploadWithNoFileIs400(): void { $this->assertSame(400, $response->getStatus()); }//end testAnUploadWithNoFileIs400() + + /** + * Every change to the folder of a frozen or archived object is a 409 naming the state, and nothing is touched. + * + * @param string $state `frozen` or `archived`. + * @param array $marker The marker the object carries. + * + * @return void + * + * @dataProvider markedObjects + */ + public function testEveryChangeOnAFrozenOrArchivedObjectIs409(string $state, array $marker): void { + if ($state === 'frozen') { + $this->object->setFrozen($marker); + } else { + $this->object->setArchived($marker); + } + + $this->fileService->expects($this->never())->method('getObjectFolder'); + $this->fileService->expects($this->never())->method('addFile'); + $this->uploading(name: 'nota.txt', content: 'x'); + + $controller = $this->controllerAs('henk', ['behandelaars']); + $responses = [ + $controller->create(register: '20', schema: '35', id: self::OBJECT_UUID, name: 'Stukken'), + $controller->upload(register: '20', schema: '35', id: self::OBJECT_UUID, path: 'Bijlagen'), + $controller->rename(register: '20', schema: '35', id: self::OBJECT_UUID, nodeId: 201, name: 'nieuw.txt'), + $controller->destroy(register: '20', schema: '35', id: self::OBJECT_UUID, nodeId: 201), + ]; + + foreach ($responses as $response) { + $this->assertSame(409, $response->getStatus()); + $this->assertSame($state, $response->getData()['state']); + $this->assertSame('alice', $response->getData()['by']); + $this->assertSame('2026-10-01T09:00:00+00:00', $response->getData()['at']); + $this->assertSame('case closed', $response->getData()['reason']); + } + }//end testEveryChangeOnAFrozenOrArchivedObjectIs409() + + /** + * The markers a frozen and an archived object carry. + * + * @return array}> + */ + public static function markedObjects(): array { + $marker = ['by' => 'alice', 'at' => '2026-10-01T09:00:00+00:00', 'reason' => 'case closed']; + + return [ + 'frozen' => ['frozen', $marker], + 'archived' => ['archived', $marker], + ]; + }//end markedObjects() + + /** + * Reading the folder of a frozen object is not a write and still works. + * + * @return void + */ + public function testAFrozenObjectsFolderCanStillBeListed(): void { + $this->object->setFrozen(['by' => 'alice', 'at' => '2026-10-01T09:00:00+00:00']); + + $response = $this->controllerAs('henk', ['behandelaars']) + ->index(register: '20', schema: '35', id: self::OBJECT_UUID); + + $this->assertSame(200, $response->getStatus()); + }//end testAFrozenObjectsFolderCanStillBeListed() + + /** + * A caller without update learns nothing of the state: 403, not 409. + * + * @return void + */ + public function testAReaderOfAFrozenObjectStillGets403(): void { + $this->object->setFrozen(['by' => 'alice']); + + $response = $this->controllerAs('bea', ['vergunningen']) + ->create(register: '20', schema: '35', id: self::OBJECT_UUID, name: 'Stukken'); + + $this->assertSame(403, $response->getStatus()); + }//end testAReaderOfAFrozenObjectStillGets403() }//end class diff --git a/tests/Unit/Controller/ObjectRelationsControllerTreeTest.php b/tests/Unit/Controller/ObjectRelationsControllerTreeTest.php index 11e2659e26..3545196add 100644 --- a/tests/Unit/Controller/ObjectRelationsControllerTreeTest.php +++ b/tests/Unit/Controller/ObjectRelationsControllerTreeTest.php @@ -24,6 +24,11 @@ use OCA\OpenRegister\Controller\ObjectRelationsController; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Service\Audit\PurposeGuard; +use OCA\OpenRegister\Service\Audit\PurposeRefusedException; use OCA\OpenRegister\Service\Export\ExportGate; use OCA\OpenRegister\Service\Object\ObjectReadAccess; use OCA\OpenRegister\Service\ObjectService; @@ -55,6 +60,26 @@ class ObjectRelationsControllerTreeTest extends TestCase { */ private ObjectReadAccess&MockObject $access; + /** + * The doelbinding guard double. + * + * @var PurposeGuard&MockObject + */ + private PurposeGuard&MockObject $purposeGuard; + + /** + * An object as the access check hands it over: it knows its register and schema. + * + * @return ObjectEntity The object. + */ + private function object(): ObjectEntity { + $object = new ObjectEntity(); + $object->setRegister('1'); + $object->setSchema('2'); + + return $object; + }//end object() + /** * Build the controller over a set of query parameters. * @@ -71,6 +96,10 @@ private function controller(array $params): ObjectRelationsController { $this->trees = $this->createMock(ObjectTreeService::class); $this->access = $this->createMock(ObjectReadAccess::class); $this->access->method('notReadable')->willReturn(new JSONResponse(data: ['error' => 'Object not found'], statusCode: 404)); + $this->access->method('schemaOf')->willReturn(new Schema()); + $this->purposeGuard = $this->createMock(PurposeGuard::class); + $registerMapper = $this->createMock(RegisterMapper::class); + $registerMapper->method('find')->willReturn(new Register()); return new ObjectRelationsController( appName: 'openregister', @@ -80,7 +109,9 @@ private function controller(array $params): ObjectRelationsController { objectService: $this->createMock(ObjectService::class), exportGate: $this->createMock(ExportGate::class), access: $this->access, - trees: $this->trees + trees: $this->trees, + purposeGuard: $this->purposeGuard, + registerMapper: $registerMapper ); }//end controller() @@ -104,7 +135,7 @@ public function testAnUnreadableRootIsNotFoundAndNotWalked(): void { */ public function testAnUnknownDirectionIsRefused(): void { $controller = $this->controller(params: ['direction' => 'sideways']); - $this->access->method('readable')->willReturn(new ObjectEntity()); + $this->access->method('readable')->willReturn($this->object()); $this->trees->expects($this->never())->method('tree'); $this->assertSame(400, $controller->tree(register: 'r', schema: 's', id: 'x')->getStatus()); @@ -117,7 +148,7 @@ public function testAnUnknownDirectionIsRefused(): void { */ public function testTheQueryReachesTheWalk(): void { $controller = $this->controller(params: ['direction' => 'both', 'depth' => '2', 'fields' => 'kvkNumber, tradeName']); - $root = new ObjectEntity(); + $root = $this->object(); $this->access->method('readable')->willReturn($root); $this->trees->expects($this->once())->method('tree') ->with($root, 'both', 2, ['kvkNumber', 'tradeName']) @@ -129,6 +160,36 @@ public function testTheQueryReachesTheWalk(): void { $this->assertSame(['nodes' => []], $response->getData()); }//end testTheQueryReachesTheWalk() + /** + * A schema that asks for a declared purpose refuses the tree like the object GET, and nothing is walked. + * + * @return void + */ + public function testARefusedPurposeAnswersItsStatusAndNothingIsWalked(): void { + $controller = $this->controller(params: []); + $this->access->method('readable')->willReturn($this->object()); + $this->purposeGuard->expects($this->once())->method('enforce') + ->willThrowException(new PurposeRefusedException(rule: PurposeRefusedException::RULE_MISSING, reason: 'A purpose is required.')); + $this->trees->expects($this->never())->method('tree'); + + $response = $controller->tree(register: 'r', schema: 's', id: 'x'); + + $this->assertSame(403, $response->getStatus()); + }//end testARefusedPurposeAnswersItsStatusAndNothingIsWalked() + + /** + * The purpose check runs after the read check: an unreadable root never reaches the guard. + * + * @return void + */ + public function testAnUnreadableRootNeverReachesThePurposeGuard(): void { + $controller = $this->controller(params: []); + $this->access->method('readable')->willReturn(null); + $this->purposeGuard->expects($this->never())->method('enforce'); + + $this->assertSame(404, $controller->tree(register: 'r', schema: 's', id: 'x')->getStatus()); + }//end testAnUnreadableRootNeverReachesThePurposeGuard() + /** * Without a depth the walk gets null, which means the schema's own bound. * @@ -136,7 +197,7 @@ public function testTheQueryReachesTheWalk(): void { */ public function testNoDepthMeansTheDeclaredBound(): void { $controller = $this->controller(params: []); - $this->access->method('readable')->willReturn(new ObjectEntity()); + $this->access->method('readable')->willReturn($this->object()); $this->trees->expects($this->once())->method('tree') ->with($this->anything(), 'descendants', null, []) ->willReturn([]); 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'); diff --git a/tests/Unit/Controller/OrganisationGrantManageGuardTest.php b/tests/Unit/Controller/OrganisationGrantManageGuardTest.php index 093edbc62a..e9aa310aa2 100644 --- a/tests/Unit/Controller/OrganisationGrantManageGuardTest.php +++ b/tests/Unit/Controller/OrganisationGrantManageGuardTest.php @@ -29,6 +29,7 @@ use OCA\OpenRegister\Db\OrganisationMapper; use OCA\OpenRegister\Db\TenantUsageMapper; use OCA\OpenRegister\Service\OrganisationService; +use OCA\OpenRegister\Service\Rbac\GrantChangeAuditor; use OCA\OpenRegister\Service\TenantLifecycleService; use OCP\IGroupManager; use OCP\IRequest; @@ -52,6 +53,8 @@ class OrganisationGrantManageGuardTest extends TestCase { private OrganisationController $controller; + private GrantChangeAuditor&MockObject $auditor; + protected function setUp(): void { parent::setUp(); @@ -67,6 +70,7 @@ protected function setUp(): void { $this->organisationMapper->method('findByUuid')->willReturn($this->organisation); $this->organisationMapper->method('save')->willReturnArgument(0); $this->organisationMapper->method('update')->willReturnArgument(0); + $this->auditor = $this->createMock(GrantChangeAuditor::class); $this->controller = new OrganisationController( 'openregister', @@ -77,7 +81,8 @@ protected function setUp(): void { $this->createMock(TenantLifecycleService::class), $this->createMock(TenantUsageMapper::class), $this->createMock(IUserSession::class), - $this->createMock(IGroupManager::class) + $this->createMock(IGroupManager::class), + $this->auditor ); }//end setUp() @@ -122,6 +127,36 @@ public function testAMemberStillEditsTheRestOfTheBlock(): void { $this->assertSame(['editors'], $this->organisation->getAuthorization()['object_publish']); }//end testAMemberStillEditsTheRestOfTheBlock() + public function testAChangeOfWhoHoldsTheRightIsRecordedOnTheTrail(): void { + $this->organisationService->method('isOrganisationAdmin')->willReturn(true); + $this->request->method('getParams')->willReturn( + ['authorization' => ['grant_manage' => ['records-service', 'second-service']]] + ); + $this->auditor->expects($this->once())->method('recordSpecialRight')->with( + 'org-1', + ['records-service'], + ['records-service', 'second-service'] + ); + + $this->controller->update('org-1'); + }//end testAChangeOfWhoHoldsTheRightIsRecordedOnTheTrail() + + public function testASaveThatLeavesTheListAloneRecordsNothing(): void { + $this->organisationService->method('isOrganisationAdmin')->willReturn(true); + $this->request->method('getParams')->willReturn(['name' => 'Gemeente Utrecht']); + $this->auditor->expects($this->never())->method('recordSpecialRight'); + + $this->controller->update('org-1'); + }//end testASaveThatLeavesTheListAloneRecordsNothing() + + public function testARefusedChangeRecordsNothing(): void { + $this->organisationService->method('isOrganisationAdmin')->willReturn(false); + $this->request->method('getParams')->willReturn(['authorization' => ['grant_manage' => []]]); + $this->auditor->expects($this->never())->method('recordSpecialRight'); + + $this->assertSame(403, $this->controller->update('org-1')->getStatus()); + }//end testARefusedChangeRecordsNothing() + public function testTheOwnerOrAnAdministratorChangesWhoHoldsTheRight(): void { $this->organisationService->method('isOrganisationAdmin')->with('org-1')->willReturn(true); $this->request->method('getParams')->willReturn( 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'); diff --git a/tests/Unit/Db/MagicBulkHandlerRawAppendTest.php b/tests/Unit/Db/MagicBulkHandlerRawAppendTest.php index e3e89bae44..93bcb616b4 100644 --- a/tests/Unit/Db/MagicBulkHandlerRawAppendTest.php +++ b/tests/Unit/Db/MagicBulkHandlerRawAppendTest.php @@ -154,4 +154,33 @@ public function testPurgeExpiredDeletesByRegisterSchemaAndPassedExpiry(): void { $this->assertSame(4, $purged); $this->assertSame(['_schema = :p22', '_expires IS NOT NULL', '_expires < NOW()'], $andWheres); } + + public function testADeclaredPropertyNamedLikeAMetadataColumnNeverReplacesTheStampedValue(): void { + $this->schema->setProperties( + [ + '_authorization' => ['type' => 'object'], + '_owner' => ['type' => 'string'], + '_organisation' => ['type' => 'string'], + '_status' => ['type' => 'string'], + '_meta' => ['type' => 'string'], + ] + ); + + $row = $this->prepare([ + '@self' => ['uuid' => 'u-3', 'owner' => 'alice', 'organisation' => 'org-1'], + 'object' => [ + '_authorization' => ['read' => ['everyone']], + '_owner' => 'mallory', + '_organisation' => 'org-2', + '_status' => 'published', + '_meta' => 'kept', + ], + ]); + + $this->assertSame('alice', $row['_owner']); + $this->assertSame('org-1', $row['_organisation']); + $this->assertArrayNotHasKey('_authorization', $row); + $this->assertArrayNotHasKey('_status', $row); + $this->assertSame('kept', $row['_meta']); + } } diff --git a/tests/Unit/Db/MagicMapper/MagicMetadataColumnTest.php b/tests/Unit/Db/MagicMapper/MagicMetadataColumnTest.php index c496c56496..ab2fcb62a9 100644 --- a/tests/Unit/Db/MagicMapper/MagicMetadataColumnTest.php +++ b/tests/Unit/Db/MagicMapper/MagicMetadataColumnTest.php @@ -79,4 +79,23 @@ public function testAttributeNamesFollowObjectEntity(): void { $this->assertSame('id', (new MagicMetadataColumn())->attribute(field: 'id')); $this->assertNull((new MagicMetadataColumn())->attribute(field: 'meta')); }//end testAttributeNamesFollowObjectEntity() + + /** + * A declared property whose column is a metadata column is never written. + * + * The spellings that sanitise to the same column are refused too. + * + * @return void + */ + public function testAPropertyThatWouldWriteAMetadataColumnIsSkipped(): void { + $roles = new MagicMetadataColumn(); + + foreach (['_owner', '_organisation', '_authorization', '_status', '_deleted', '_uuid', '_register', '_schema', '_locked', '_frozen', '_archived', '_expires', '_Owner', '__owner', '_owner_', '_schemaVersion'] as $key) { + $this->assertTrue($roles->skippedOnWrite(key: $key, schemaProperties: [$key => ['type' => 'string']]), $key); + $this->assertTrue($roles->shadowsMetadata(key: $key), $key); + } + + $this->assertFalse($roles->shadowsMetadata(key: '_meta')); + $this->assertFalse($roles->shadowsMetadata(key: 'owner')); + }//end testAPropertyThatWouldWriteAMetadataColumnIsSkipped() }//end class 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. */ diff --git a/tests/Unit/Db/TaskMapperExternalTest.php b/tests/Unit/Db/TaskMapperExternalTest.php index 6975622434..46c876722b 100644 --- a/tests/Unit/Db/TaskMapperExternalTest.php +++ b/tests/Unit/Db/TaskMapperExternalTest.php @@ -86,4 +86,20 @@ public function testThePartyFindersPredicateOnPartyTypeAndOpenness(): void { $this->assertTrue($this->saw('expr.eq', 'is_terminal')); $this->assertSame(0, (new TaskMapper(db: $this->connectionWith(rows: [])))->countOpenExternalForParty(partyReference: 'party:nobody')); }//end testThePartyFindersPredicateOnPartyTypeAndOpenness() + + /** + * A portal task held back by an open blocker is in neither the party's page nor its total. + * + * @return void + */ + public function testThePartyFindersLeaveOutATaskWhoseBlockerIsOpen(): void { + $mapper = new TaskMapper(db: $this->connectionWith(rows: [])); + $mapper->findOpenExternalForParty(partyReference: 'party:bsn-1'); + $this->assertTrue($this->saw('expr.notIn', 'blocked_by'), 'the page excludes a task whose blocker is open'); + $this->assertTrue($this->saw('expr.isNull', 'blocked_by'), 'a task with no blocker stays listed'); + + $this->calls = []; + (new TaskMapper(db: $this->connectionWith(rows: [['total' => 0]])))->countOpenExternalForParty(partyReference: 'party:bsn-1'); + $this->assertTrue($this->saw('expr.notIn', 'blocked_by'), 'the total excludes it too, over the same predicate'); + }//end testThePartyFindersLeaveOutATaskWhoseBlockerIsOpen() }//end class diff --git a/tests/Unit/Listener/DecisionTableValidationListenerTest.php b/tests/Unit/Listener/DecisionTableValidationListenerTest.php index 3b73f7585d..c03508e007 100644 --- a/tests/Unit/Listener/DecisionTableValidationListenerTest.php +++ b/tests/Unit/Listener/DecisionTableValidationListenerTest.php @@ -78,6 +78,65 @@ public function testASecondTableUnderTheSameKeyIsRefused(): void { $this->assertStringContainsString('already used by decision table u-2', $event->getErrors()['message']); } + /** + * A key clash that cannot be checked is not "no clash": the save is refused, since a duplicate key lets the + * store pick either table at random. + * + * @return void + */ + public function testAKeyThatCannotBeCheckedRefusesTheSave(): void { + $schema = new Schema(); + $schema->setSlug('decision-table'); + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturn($schema); + $objects = $this->createMock(ObjectService::class); + $objects->method('searchObjectsBySlug')->willThrowException(new \RuntimeException('search is down')); + $register = new Register(); + $register->setSlug('decision-tables'); + $registers = $this->createMock(RegisterMapper::class); + $registers->method('find')->willReturn($register); + $listener = new DecisionTableValidationListener($schemas, new DecisionTableValidator(), $objects, $registers); + + $event = new ObjectCreatingEvent($this->row(DecisionTableStoreTest::shippingTable())); + $listener->handle($event); + + $this->assertTrue($event->isPropagationStopped()); + $this->assertSame(DecisionTableValidationListener::ERROR_CODE, $event->getErrors()['code']); + $this->assertStringContainsString('the key "parcel-carrier" could not be checked', $event->getErrors()['message']); + }//end testAKeyThatCannotBeCheckedRefusesTheSave() + + /** + * A schema or register that cannot be READ (as opposed to one that does not exist) cannot be told from the + * decision-table one, so the save is refused instead of waved through unvalidated. + * + * @return void + */ + public function testASchemaOrRegisterThatCannotBeReadRefusesTheSave(): void { + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willThrowException(new \RuntimeException('database is down')); + $listener = new DecisionTableValidationListener( + $schemas, + new DecisionTableValidator(), + $this->createMock(ObjectService::class), + $this->createMock(RegisterMapper::class) + ); + $event = new ObjectCreatingEvent($this->row(['anything' => true])); + $listener->handle($event); + $this->assertTrue($event->isPropagationStopped()); + $this->assertStringContainsString('could not be checked against the decision-table rules', $event->getErrors()['message']); + + $schema = new Schema(); + $schema->setSlug('decision-table'); + $schemas = $this->createMock(SchemaMapper::class); + $schemas->method('find')->willReturn($schema); + $registers = $this->createMock(RegisterMapper::class); + $registers->method('find')->willThrowException(new \RuntimeException('database is down')); + $listener = new DecisionTableValidationListener($schemas, new DecisionTableValidator(), $this->createMock(ObjectService::class), $registers); + $event = new ObjectCreatingEvent($this->row(DecisionTableStoreTest::shippingTable())); + $listener->handle($event); + $this->assertTrue($event->isPropagationStopped()); + }//end testASchemaOrRegisterThatCannotBeReadRefusesTheSave() + public function testUpdatingATableKeepsItsOwnKey(): void { $self = $this->row(DecisionTableStoreTest::shippingTable()); $event = new ObjectUpdatingEvent($this->row(DecisionTableStoreTest::shippingTable()), $self); diff --git a/tests/Unit/Listener/FormDestinationSchemaListenerTest.php b/tests/Unit/Listener/FormDestinationSchemaListenerTest.php new file mode 100644 index 0000000000..19f583483d --- /dev/null +++ b/tests/Unit/Listener/FormDestinationSchemaListenerTest.php @@ -0,0 +1,151 @@ + + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Listener + * + * @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/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Listener; + +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Event\SchemaUpdatedEvent; +use OCA\OpenRegister\Listener\FormDestinationSchemaListener; +use OCA\OpenRegister\Service\Form\FormDependentsCheck; +use OCP\EventDispatcher\Event; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * @covers \OCA\OpenRegister\Listener\FormDestinationSchemaListener + * @uses \OCA\OpenRegister\Event\SchemaUpdatedEvent + * @uses \OCA\OpenRegister\Db\Schema + */ +class FormDestinationSchemaListenerTest extends TestCase { + + private ContainerInterface&MockObject $container; + + private LoggerInterface&MockObject $logger; + + private FormDependentsCheck&MockObject $check; + + private FormDestinationSchemaListener $listener; + + protected function setUp(): void { + parent::setUp(); + + $this->check = $this->createMock(FormDependentsCheck::class); + $this->container = $this->createMock(ContainerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->listener = new FormDestinationSchemaListener($this->container, $this->logger); + }//end setUp() + + /** + * A schema. + * + * @param array $properties The properties. + * @param array $required The required list. + * + * @return Schema The schema. + */ + private function schema(array $properties, array $required = []): Schema { + $schema = new Schema(); + $schema->setId(12); + $schema->setSlug('aanvraag'); + $schema->setProperties($properties); + $schema->setRequired($required); + + return $schema; + }//end schema() + + /** + * The event of a write that adds a required property. + * + * @return SchemaUpdatedEvent The event. + */ + private function addsARequiredProperty(): SchemaUpdatedEvent { + return new SchemaUpdatedEvent( + newSchema: $this->schema(properties: ['naam' => ['type' => 'string'], 'bsn' => ['type' => 'string']], required: ['bsn']), + oldSchema: $this->schema(properties: ['naam' => ['type' => 'string']]) + ); + }//end addsARequiredProperty() + + public function testAWriteThatChangedNeitherPropertiesNorRequiredNeverBuildsTheCheck(): void { + $this->container->expects($this->never())->method('get'); + + $properties = ['naam' => ['type' => 'string'], 'bsn' => ['type' => 'string']]; + $this->listener->handle( + new SchemaUpdatedEvent( + newSchema: $this->schema(properties: array_reverse($properties, true), required: ['bsn']), + oldSchema: $this->schema(properties: $properties, required: ['bsn']) + ) + ); + }//end testAWriteThatChangedNeitherPropertiesNorRequiredNeverBuildsTheCheck() + + public function testAnImportThatAddsARequiredPropertyUnpublishesTheBrokenForms(): void { + $assessment = ['refused' => false, 'schema' => 'aanvraag', 'affected' => [['id' => 'form-1']]]; + $this->container->method('get')->with(FormDependentsCheck::class)->willReturn($this->check); + $this->check->method('isClaimed')->willReturn(false); + $this->check->expects($this->once())->method('assess')->willReturn($assessment); + $this->check->expects($this->once())->method('apply')->with($assessment); + + $this->listener->handle($this->addsARequiredProperty()); + }//end testAnImportThatAddsARequiredPropertyUnpublishesTheBrokenForms() + + public function testARefuseSettingCannotBeHonouredWithoutAResponseAndSaysSo(): void { + $this->container->method('get')->willReturn($this->check); + $this->check->method('isClaimed')->willReturn(false); + $this->check->method('assess')->willReturn(['refused' => true, 'schema' => 'aanvraag', 'affected' => [['id' => 'form-1']]]); + $this->logger->expects($this->once())->method('warning')->with($this->stringContains('has no response to refuse'), $this->anything()); + $this->check->expects($this->once())->method('apply')->with(['refused' => false, 'schema' => 'aanvraag', 'affected' => [['id' => 'form-1']]]); + + $this->listener->handle($this->addsARequiredProperty()); + }//end testARefuseSettingCannotBeHonouredWithoutAResponseAndSaysSo() + + public function testASaveTheSchemaApiClaimedIsLeftToIt(): void { + $this->container->method('get')->willReturn($this->check); + $this->check->method('isClaimed')->with(12)->willReturn(true); + $this->check->expects($this->never())->method('assess'); + $this->check->expects($this->never())->method('apply'); + + $this->listener->handle($this->addsARequiredProperty()); + }//end testASaveTheSchemaApiClaimedIsLeftToIt() + + public function testNothingIsUnpublishedWhenNoFormIsBroken(): void { + $this->container->method('get')->willReturn($this->check); + $this->check->method('isClaimed')->willReturn(false); + $this->check->method('assess')->willReturn(['refused' => false, 'schema' => 'aanvraag', 'affected' => []]); + $this->check->expects($this->never())->method('apply'); + + $this->listener->handle($this->addsARequiredProperty()); + }//end testNothingIsUnpublishedWhenNoFormIsBroken() + + public function testACheckThatCannotBeBuiltIsLoggedAndDoesNotUndoTheWrite(): void { + $this->container->method('get')->willThrowException(new \RuntimeException('container failure')); + $this->logger->expects($this->once())->method('warning')->with($this->stringContains('could not be re-checked'), $this->anything()); + + $this->listener->handle($this->addsARequiredProperty()); + }//end testACheckThatCannotBeBuiltIsLoggedAndDoesNotUndoTheWrite() + + public function testAnotherEventIsIgnored(): void { + $this->container->expects($this->never())->method('get'); + + $this->listener->handle(new Event()); + }//end testAnotherEventIsIgnored() +}//end class diff --git a/tests/Unit/Listener/FrozenNodeWriteListenerTest.php b/tests/Unit/Listener/FrozenNodeWriteListenerTest.php index 364a4d31be..42bc54d2e9 100644 --- a/tests/Unit/Listener/FrozenNodeWriteListenerTest.php +++ b/tests/Unit/Listener/FrozenNodeWriteListenerTest.php @@ -35,10 +35,14 @@ 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\Container\ContainerInterface; use Psr\Log\LoggerInterface; /** @@ -54,16 +58,69 @@ class FrozenNodeWriteListenerTest extends TestCase { private LoggerInterface&MockObject $logger; + private IRootFolder&MockObject $rootFolder; + + private ContainerInterface&MockObject $container; + + /** + * How many times the listener asked the container for the mapper. + * + * @var integer + */ + private int $mapperRequests = 0; + protected function setUp(): void { parent::setUp(); $this->objects = $this->createMock(MagicMapper::class); + $this->mapperRequests = 0; + $this->container = $this->createMock(ContainerInterface::class); + $this->container->method('get')->willReturnCallback( + function (string $id): MagicMapper { + $this->assertSame(MagicMapper::class, $id); + $this->mapperRequests++; + + return $this->objects; + } + ); $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(), container: $this->container, 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 +199,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); @@ -288,4 +408,42 @@ public function testAnOtherEventIsIgnored(): void { $this->listener()->handle(new \OCP\Files\Events\Node\BeforeNodeReadEvent($this->createMock(Node::class))); $this->addToAssertionCount(1); }//end testAnOtherEventIsIgnored() + + public function testTheMapperIsNotBuiltForANodeOutsideEveryObjectFolder(): void { + $files = $this->folder('files', 2, '/openregister/files', null); + $loose = $this->createMock(File::class); + $loose->method('getName')->willReturn('loose.pdf'); + $loose->method('getPath')->willReturn('/openregister/files/loose.pdf'); + $loose->method('getParent')->willReturn($files); + + $listener = $this->listener(); + $listener->handle(new BeforeNodeWrittenEvent($loose)); + $listener->handle(new BeforeNodeWrittenEvent($this->fileThroughAShare(ownerUid: 'bob'))); + $listener->handle(new BeforeNodeCreatedEvent($this->fileInObjectFolder('/alice/files'))); + $listener->handle(new BeforeNodeDeletedEvent($this->fileInObjectFolder('/alice/files'))); + + $this->assertSame(0, $this->mapperRequests); + }//end testTheMapperIsNotBuiltForANodeOutsideEveryObjectFolder() + + public function testTheMapperIsFetchedOnceANodeProvesToBeInAnObjectFolder(): void { + $this->objects->method('findAcrossAllSources')->willReturn(['object' => $this->frozenObject()]); + + try { + $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileInObjectFolder())); + $this->fail('Expected the write to be refused'); + } catch (HintException $e) { + $this->assertSame(1, $this->mapperRequests); + } + }//end testTheMapperIsFetchedOnceANodeProvesToBeInAnObjectFolder() + + public function testAMapperThatCannotBeBuiltRefusesTheWrite(): void { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willThrowException(new \RuntimeException('container failure')); + $this->logger->expects($this->once())->method('warning')->with($this->stringContains('could not be resolved'), $this->anything()); + + $listener = new FrozenNodeWriteListener(guard: new FileWriteGuard(), container: $container, logger: $this->logger, rootFolder: $this->rootFolder); + + $this->expectException(HintException::class); + $listener->handle(new BeforeNodeWrittenEvent($this->fileInObjectFolder())); + }//end testAMapperThatCannotBeBuiltRefusesTheWrite() }//end class diff --git a/tests/Unit/Mcp/AttributeToolScannerTest.php b/tests/Unit/Mcp/AttributeToolScannerTest.php index e9c585c8cf..aae5ce1726 100644 --- a/tests/Unit/Mcp/AttributeToolScannerTest.php +++ b/tests/Unit/Mcp/AttributeToolScannerTest.php @@ -356,7 +356,7 @@ public function testUndeclaredAnnotationsStayOmitted(): void { }//end testUndeclaredAnnotationsStayOmitted() public function testMalformedAnnotationsAreRejectedAndLogged(): void { - $this->logger->expects($this->once()) + $this->logger->expects($this->exactly(2)) ->method('warning') ->with($this->stringContains('unrecognised `annotations`')); @@ -367,4 +367,23 @@ public function testMalformedAnnotationsAreRejectedAndLogged(): void { $this->assertArrayHasKey('reassignCase', $descriptors); }//end testMalformedAnnotationsAreRejectedAndLogged() + + public function testAReservedMcpHintKeyInAnnotationsIsRejectedAndLogged(): void { + $logged = []; + $this->logger->method('warning')->willReturnCallback( + function (string $message, array $context = []) use (&$logged): void { + $logged[] = ['message' => $message, 'context' => $context]; + } + ); + + $descriptors = $this->scanAnnotationFixture(); + + $this->assertArrayNotHasKey('hintClaimingTool', $descriptors); + $this->assertArrayHasKey('fileCase', $descriptors); + $named = array_values(array_filter($logged, static fn (array $entry): bool => (($entry['context']['method'] ?? '') === 'hintClaimingTool'))); + $this->assertCount(1, $named); + $this->assertStringContainsString('unrecognised `annotations`', $named[0]['message']); + $this->assertSame('readOnlyHint', $named[0]['context']['annotations']); + + }//end testAReservedMcpHintKeyInAnnotationsIsRejectedAndLogged() }//end class diff --git a/tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php b/tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php index 3d79b70bfe..0908e0cd21 100644 --- a/tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php +++ b/tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php @@ -4,7 +4,8 @@ * Test fixture only: attributed methods exercising the optional * `annotations` map on `#[McpTool]` (REQ-ATTR-007). One method declares a * mark, one declares none (an empty map is never forwarded), and one - * declares a nested value (rejected at scan time). + * declares a nested value, and one declares a reserved MCP hint key (both + * rejected at scan time). * * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. @@ -44,4 +45,9 @@ public function reassignCase(string $id): array { public function badAnnotationTool(string $id): array { return ['id' => $id]; }//end badAnnotationTool() + + #[McpTool(scope: 'update', annotations: ['readOnlyHint' => true])] + public function hintClaimingTool(string $id): array { + return ['id' => $id]; + }//end hintClaimingTool() }//end class 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'); 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/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), 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']]); diff --git a/tests/Unit/Service/Anonymisation/HiddenDecisionTest.php b/tests/Unit/Service/Anonymisation/HiddenDecisionTest.php index 53fee34e37..c5897a92dc 100644 --- a/tests/Unit/Service/Anonymisation/HiddenDecisionTest.php +++ b/tests/Unit/Service/Anonymisation/HiddenDecisionTest.php @@ -119,6 +119,8 @@ private function relation(?string $decisionUuid=self::DECISION): EntityRelation $relation->setPositionEnd(20); $relation->setBases(['5.1.2e']); $relation->setSkipAnonymization(false); + $relation->setAnonymized(true); + $relation->setAnonymizedValue('[REDACTED]'); $relation->setDecisionUuid($decisionUuid); return $relation; @@ -135,6 +137,8 @@ public function testAnotherReviewersDecisionIsHidden(): void { self::assertSame(DecisionVisibility::HIDDEN, $data['decision']); self::assertNull($data['bases']); self::assertNull($data['skipAnonymization']); + self::assertNull($data['anonymized'], 'an anonymise pass would otherwise show the verdict'); + self::assertNull($data['anonymizedValue']); self::assertSame(12, $data['id']); self::assertSame(10, $data['positionStart']); self::assertSame(20, $data['positionEnd']); @@ -152,6 +156,8 @@ public function testTheSupervisorSeesIt(): void { self::assertSame(self::DECISION, $data['decision'], $userId); self::assertSame(['5.1.2e'], $data['bases'], $userId); self::assertFalse($data['skipAnonymization'], $userId); + self::assertTrue($data['anonymized'], $userId); + self::assertSame('[REDACTED]', $data['anonymizedValue'], $userId); } }//end testTheSupervisorSeesIt() diff --git a/tests/Unit/Service/Audit/ReadableAuditTrailListerTest.php b/tests/Unit/Service/Audit/ReadableAuditTrailListerTest.php index 24cf5370c5..bf8431a47d 100644 --- a/tests/Unit/Service/Audit/ReadableAuditTrailListerTest.php +++ b/tests/Unit/Service/Audit/ReadableAuditTrailListerTest.php @@ -35,8 +35,16 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\Audit\ReadableAuditTrailLister; +use OCA\OpenRegister\Service\ConditionMatcher; use OCA\OpenRegister\Service\Object\PermissionHandler; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; /** * @covers \OCA\OpenRegister\Service\Audit\ReadableAuditTrailLister @@ -184,6 +192,88 @@ public function testAnUnreadableObjectsEntryIsAbsent(): void { $this->assertCount(1, $page['results']); }//end testAnUnreadableObjectsEntryIsAbsent() + /** + * A lister over the REAL permission handler, for one caller and one draft owned by somebody else. + * + * @param string $caller The signed-in user. + * + * @return array The uuids of the entries the caller gets back. + */ + private function draftEntriesFor(string $caller): array { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($caller); + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + $userManager = $this->createMock(IUserManager::class); + $userManager->method('get')->willReturn($user); + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('getUserGroupIds')->willReturn(['staff']); + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willThrowException(new \RuntimeException('not available')); + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool')->willReturnCallback( + static fn (string $app, string $key, bool $default=false): bool => $default + ); + + // The schema lets every signed-in user read: only the draft rule can refuse. + $schema = new Schema(); + $schema->setId(7); + $schema->setAuthorization(['read' => ['authenticated']]); + $schemaMapper = $this->createMock(SchemaMapper::class); + $schemaMapper->method('find')->willReturn($schema); + + $draft = $this->object(uuid: 'obj-draft'); + $draft->setStatus('draft'); + $draft->setOwner('alice'); + + $objectMapper = $this->createMock(MagicMapper::class); + $objectMapper->method('findMultipleAcrossAllMagicTables')->willReturn([$draft]); + $auditMapper = $this->createMock(AuditTrailMapper::class); + $auditMapper->method('findAll')->willReturnCallback( + fn (?int $limit = null, ?int $offset = null): array => (($offset !== null && $offset > 0) ? [] : [$this->entry(objectUuid: 'obj-draft', action: 'create')]) + ); + + $lister = new ReadableAuditTrailLister( + auditTrailMapper: $auditMapper, + objectMapper: $objectMapper, + schemaMapper: $schemaMapper, + permissionHandler: new PermissionHandler( + userSession: $session, + userManager: $userManager, + groupManager: $groupManager, + schemaMapper: $schemaMapper, + objectEntityMapper: $objectMapper, + conditionMatcher: $this->createMock(ConditionMatcher::class), + appConfig: $appConfig, + logger: $this->createMock(LoggerInterface::class), + container: $container, + ) + ); + + return array_column($lister->page(userId: $caller, limit: 20)['results'], 'objectUuid'); + }//end draftEntriesFor() + + /** + * What a user typed into a draft is not in the readable trail of anybody else (the draft rule of hasPermission()). + * + * Goes red without the draft rule: the schema lets bob read, and the entry + * holds the draft's values in its `changed` column. + * + * @return void + */ + public function testSomebodyElsesDraftHasNoEntriesInTheTrail(): void { + $this->assertSame([], $this->draftEntriesFor(caller: 'bob'), "bob got the audit entries of alice's draft."); + }//end testSomebodyElsesDraftHasNoEntriesInTheTrail() + + /** + * The owner still reads the entries of their own draft. + * + * @return void + */ + public function testTheOwnerStillGetsTheEntriesOfTheirDraft(): void { + $this->assertSame(['obj-draft'], $this->draftEntriesFor(caller: 'alice')); + }//end testTheOwnerStillGetsTheEntriesOfTheirDraft() + /** * An anonymous caller gets nothing, and the mapper is never asked. * diff --git a/tests/Unit/Service/Calculation/GraduatedRateTest.php b/tests/Unit/Service/Calculation/GraduatedRateTest.php index a822f9b899..44181d6d53 100644 --- a/tests/Unit/Service/Calculation/GraduatedRateTest.php +++ b/tests/Unit/Service/Calculation/GraduatedRateTest.php @@ -127,6 +127,22 @@ public function testAMalformedScheduleIsRefused(mixed $bands): void { $this->fee(4, $bands); } + /** + * `bandRate` refuses a malformed schedule at a unit of zero or less too, as `graduated` does at any quantity. + * + * @dataProvider malformedSchedules + */ + public function testAMalformedScheduleIsRefusedByBandRateAtAUnitOfZero(mixed $bands): void { + $this->expectException(EvaluationException::class); + $this->eval->evaluate(['u' => 0, 'b' => $bands], ['bandRate' => [['prop' => 'u'], ['prop' => 'b']]]); + } + + public function testBandRateRefusesAMalformedScheduleAtANegativeUnitAndStillAnswersNullForAGoodOne(): void { + $this->assertNull($this->eval->evaluate(['u' => -2, 'b' => self::BANDS], ['bandRate' => [['prop' => 'u'], ['prop' => 'b']]])); + $this->expectException(EvaluationException::class); + $this->eval->evaluate(['u' => -2, 'b' => []], ['bandRate' => [['prop' => 'u'], ['prop' => 'b']]]); + } + public function testANonNumericQuantityIsRefused(): void { $this->expectException(EvaluationException::class); $this->fee('four'); diff --git a/tests/Unit/Service/Case/CasePlanEnsurerTest.php b/tests/Unit/Service/Case/CasePlanEnsurerTest.php index 0db7fb266a..dd9ef782e5 100644 --- a/tests/Unit/Service/Case/CasePlanEnsurerTest.php +++ b/tests/Unit/Service/Case/CasePlanEnsurerTest.php @@ -28,6 +28,11 @@ use OCA\OpenRegister\Exception\CaseValidationException; use OCA\OpenRegister\Service\Case\CasePlanDefinition; use OCA\OpenRegister\Service\Case\CasePlanEnsurer; +use OCA\OpenRegister\Service\Case\CasePlanItemTimers; +use OCA\OpenRegister\Service\Case\UnarmedTimerMarkers; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; +use Psr\Log\NullLogger; use OCA\OpenRegister\Service\Case\CasePlanTransitions; use OCA\OpenRegister\Service\Case\CaseSentryEvaluator; use OCA\OpenRegister\Service\Flow\EventCatalogService; @@ -56,9 +61,11 @@ protected function setUp(): void { /** * The ensurer under test. * + * @param CasePlanItemTimers|null $timers The deadline arming, when the test is about it. + * * @return CasePlanEnsurer The ensurer. */ - private function ensurer(): CasePlanEnsurer { + private function ensurer(?CasePlanItemTimers $timers = null): CasePlanEnsurer { $db = $this->createMock(IDBConnection::class); return new CasePlanEnsurer( @@ -66,7 +73,8 @@ private function ensurer(): CasePlanEnsurer { audits: $this->audits, definitions: new CasePlanDefinition(sentries: new CaseSentryEvaluator(catalog: new EventCatalogService())), transitions: new CasePlanTransitions(), - db: $db + db: $db, + timers: $timers ); }//end ensurer() @@ -268,4 +276,89 @@ public function testDefinitionValidationStillApplies(): void { $this->expectExceptionMessage("'case.item.started'"); $this->ensurer()->ensure(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 1, definition: $bad, history: [], actor: 'system:dossiq'); }//end testDefinitionValidationStillApplies() -}//end class + + /** + * An item ensured as active under `statutory` with an `expiresAt` gets its + * enforcing expiry timer (and a `dueAt` its advisory one): no transition + * event is dispatched for an inserted state, so the ensurer arms them. A + * second run, which creates nothing, arms nothing again. + * + * @return void + */ + public function testAnItemEnsuredAsActiveArmsItsStatutoryExpiryAndDueTimers(): void { + $configs = []; + $core = $this->createMock(FlowTimerService::class); + $core->method('arm')->willReturnCallback( + static function (array $config) use (&$configs): FlowTimer { + $configs[] = $config; + + return new FlowTimer(); + } + ); + $timers = new CasePlanItemTimers(timers: $core, logger: new NullLogger()); + $definition = [ + 'settings' => ['authorization' => ['demo-behandelaars'], 'statutory' => ['beoordeling']], + 'items' => [ + [ + 'key' => 'beoordeling', + 'type' => CaseItem::TYPE_HUMAN_TASK, + 'name' => 'Beoordeling', + 'state' => CaseItem::STATE_ACTIVE, + 'dueAt' => (new \DateTime('+5 days'))->format('c'), + 'expiresAt' => (new \DateTime('+10 days'))->format('c'), + ], + ['key' => 'wacht', 'type' => CaseItem::TYPE_HUMAN_TASK, 'name' => 'Wacht', 'state' => CaseItem::STATE_ENABLED, 'expiresAt' => (new \DateTime('+10 days'))->format('c')], + ], + ]; + + $this->ensurer($timers)->ensure(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 2, definition: $definition, history: [], actor: 'system:dossiq'); + + $this->assertCount(2, $configs, 'an expiry and a due timer for the active item, none for the enabled one'); + $byPurpose = []; + foreach ($configs as $config) { + $byPurpose[$config['purpose']] = $config; + } + + $this->assertSame('wettelijk', $byPurpose['expiry']['legalEffect']); + $this->assertSame('transition:terminate', $byPurpose['expiry']['onExpiry']); + $this->assertSame('case-item', $byPurpose['expiry']['subjectType']); + $this->assertSame('none', $byPurpose['due']['legalEffect']); + + $this->ensurer($timers)->ensure(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 2, definition: $definition, history: [], actor: 'system:dossiq'); + $this->assertCount(2, $configs, 'a second run creates nothing and arms nothing'); + }//end testAnItemEnsuredAsActiveArmsItsStatutoryExpiryAndDueTimers() + + /** + * A refused statutory arm during an ensure never undoes it: the items are + * written, and the item is marked for the re-arm pass. + * + * @return void + */ + public function testARefusedStatutoryArmDuringEnsureIsMarkedAndTheEnsureStands(): void { + $core = $this->createMock(FlowTimerService::class); + $core->method('arm')->willThrowException(new \RuntimeException('calendar unknown')); + $stored = ''; + $appConfig = $this->createMock(\OCP\IAppConfig::class); + $appConfig->method('getValueString')->willReturnCallback(static function () use (&$stored): string { + return $stored; + }); + $appConfig->method('setValueString')->willReturnCallback(static function (string $app, string $key, string $value) use (&$stored): bool { + $stored = $value; + + return true; + }); + $timers = new CasePlanItemTimers(timers: $core, logger: new NullLogger(), markers: new UnarmedTimerMarkers(appConfig: $appConfig)); + $definition = [ + 'settings' => ['authorization' => ['demo-behandelaars'], 'statutory' => ['beoordeling']], + 'items' => [ + ['key' => 'beoordeling', 'type' => CaseItem::TYPE_HUMAN_TASK, 'name' => 'B', 'state' => CaseItem::STATE_ACTIVE, 'expiresAt' => (new \DateTime('+10 days'))->format('c')], + ], + ]; + + $result = $this->ensurer($timers)->ensure(objectUuid: CaseFixtures::OBJECT, registerId: 1, schemaId: 2, definition: $definition, history: [], actor: 'system:dossiq'); + + $this->assertSame(['beoordeling'], $result['created']); + $row = $this->items->findByObject(objectUuid: CaseFixtures::OBJECT)[0]; + $this->assertSame([(string)$row->getUuid()], array_keys((array)json_decode($stored, true)), 'the item is marked'); + }//end testARefusedStatutoryArmDuringEnsureIsMarkedAndTheEnsureStands() +} diff --git a/tests/Unit/Service/Case/CasePlanItemTimersTest.php b/tests/Unit/Service/Case/CasePlanItemTimersTest.php index 8df6af8a82..4758763cf2 100644 --- a/tests/Unit/Service/Case/CasePlanItemTimersTest.php +++ b/tests/Unit/Service/Case/CasePlanItemTimersTest.php @@ -28,6 +28,7 @@ use OCA\OpenRegister\Db\FlowTimer; use OCA\OpenRegister\Event\FlowTimerFiredEvent; use OCA\OpenRegister\Service\Case\CasePlanItemTimers; +use OCA\OpenRegister\Service\Case\UnarmedTimerMarkers; use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -162,6 +163,52 @@ 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 logged at error level naming case, item and legal effect; the transition stands. + * + * @return 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']]); + $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]; + } + ); + $stored = ''; + $appConfig = $this->createMock(\OCP\IAppConfig::class); + $appConfig->method('getValueString')->willReturnCallback(static function () use (&$stored): string { + return $stored; + }); + $appConfig->method('setValueString')->willReturnCallback(static function (string $app, string $key, string $value) use (&$stored): bool { + $stored = $value; + + return true; + }); + $markers = new UnarmedTimerMarkers(appConfig: $appConfig); + $service = new CasePlanItemTimers(timers: $this->timers, logger: $logger, markers: $markers); + + $this->assertSame(0, $service->onEntered(item: $statutory, actor: null, now: $now)); + + $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']); + $this->assertSame(['item-9'], array_keys($markers->all()), 'a statutory item that lacks its timer is marked for the re-arm pass'); + + // A service-norm expiry that is refused stays a warning: no marker. + $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')); + $service->onEntered(item: $norm, actor: null, now: $now); + $this->assertSame(['item-9'], array_keys($markers->all())); + }//end testARefusedStatutoryTimerIsLoggedLoudly() + /** * Reaching a terminal state cancels the item's open timers; a failure to * cancel is logged, never thrown. diff --git a/tests/Unit/Service/Case/CasePlanTimerRearmTest.php b/tests/Unit/Service/Case/CasePlanTimerRearmTest.php new file mode 100644 index 0000000000..a9a90e5056 --- /dev/null +++ b/tests/Unit/Service/Case/CasePlanTimerRearmTest.php @@ -0,0 +1,216 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Case; + +use DateTime; +use DateTimeImmutable; +use OCA\OpenRegister\Db\CaseItem; +use OCA\OpenRegister\Db\FlowTimer; +use OCA\OpenRegister\Db\FlowTimerMapper; +use OCA\OpenRegister\Service\Case\CasePlanItemTimers; +use OCA\OpenRegister\Service\Case\CasePlanService; +use OCA\OpenRegister\Service\Case\CasePlanTimerRearm; +use OCA\OpenRegister\Service\Case\UnarmedTimerMarkers; +use OCA\OpenRegister\Service\Flow\Timer\FlowTimerService; +use OCP\IAppConfig; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +/** + * The re-arm pass over plan items whose statutory expiry timer could not be armed. + * + * @covers \OCA\OpenRegister\Service\Case\CasePlanTimerRearm + * @covers \OCA\OpenRegister\Service\Case\UnarmedTimerMarkers + * @covers \OCA\OpenRegister\Service\Case\CasePlanItemTimers + */ +class CasePlanTimerRearmTest extends TestCase { + + private FakeCaseItemMapper $items; + + private FlowTimerService&MockObject $core; + + private FlowTimerMapper&MockObject $timerRows; + + private CasePlanService&MockObject $plans; + + private UnarmedTimerMarkers $markers; + + private string $stored = ''; + + /** + * Fresh tables, an in-memory marker store and a clock core. + * + * @return void + */ + protected function setUp(): void { + $this->items = new FakeCaseItemMapper($this); + $this->core = $this->createMock(FlowTimerService::class); + $this->timerRows = $this->createMock(FlowTimerMapper::class); + $this->plans = $this->createMock(CasePlanService::class); + $this->stored = ''; + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturnCallback(fn (): string => $this->stored); + $appConfig->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value): bool { + $this->stored = $value; + + return true; + } + ); + $appConfig->method('deleteKey')->willReturnCallback( + function (): void { + $this->stored = ''; + } + ); + $this->markers = new UnarmedTimerMarkers(appConfig: $appConfig); + }//end setUp() + + /** + * The pass under test. + * + * @return CasePlanTimerRearm The pass. + */ + private function pass(): CasePlanTimerRearm { + return new CasePlanTimerRearm( + markers: $this->markers, + items: $this->items, + timerRows: $this->timerRows, + timers: new CasePlanItemTimers(timers: $this->core, logger: new NullLogger(), markers: $this->markers), + plans: $this->plans, + logger: new NullLogger() + ); + }//end pass() + + /** + * A statutory item that is active and marked. + * + * @param string $state The item's state. + * @param string $expires The expiry moment, relative to the pass's clock. + * + * @return CaseItem The marked row. + */ + private function markedItem(string $state = CaseItem::STATE_ACTIVE, string $expires = '+10 days'): CaseItem { + $row = CaseFixtures::row(id: 1, key: 'beoordeling', type: CaseItem::TYPE_HUMAN_TASK, state: $state); + $row->setExpiresAt(new DateTime($expires)); + $row->setPlanSettings(['statutory' => ['beoordeling']]); + $this->items->seed([$row]); + $this->markers->add(itemUuid: (string)$row->getUuid()); + + return $row; + }//end markedItem() + + /** + * A marked active item without a timer is armed with the enforcing outcome, and its marker goes. + * + * @return void + */ + public function testAMarkedActiveItemGetsItsEnforcingTimerAndLosesItsMarker(): void { + $row = $this->markedItem(); + $this->timerRows->method('findBySubject')->willReturn([]); + $configs = []; + $this->core->method('arm')->willReturnCallback( + static function (array $config) use (&$configs): FlowTimer { + $configs[] = $config; + + return new FlowTimer(); + } + ); + + $result = $this->pass()->run(now: new DateTimeImmutable()); + + $this->assertSame(['armed' => 1, 'enforced' => 0, 'dropped' => 0, 'failed' => 0], $result); + $this->assertCount(1, $configs); + $this->assertSame('expiry', $configs[0]['purpose']); + $this->assertSame('transition:terminate', $configs[0]['onExpiry']); + $this->assertSame((string)$row->getUuid(), $configs[0]['subjectUuid']); + $this->assertSame([], $this->markers->all()); + }//end testAMarkedActiveItemGetsItsEnforcingTimerAndLosesItsMarker() + + /** + * An arm that is refused again keeps the marker for the next pass. + * + * @return void + */ + public function testARefusedArmKeepsTheMarker(): void { + $row = $this->markedItem(); + $this->timerRows->method('findBySubject')->willReturn([]); + $this->core->method('arm')->willThrowException(new RuntimeException('calendar unknown')); + + $result = $this->pass()->run(now: new DateTimeImmutable()); + + $this->assertSame(['armed' => 0, 'enforced' => 0, 'dropped' => 0, 'failed' => 1], $result); + $this->assertSame([(string)$row->getUuid()], array_keys($this->markers->all())); + }//end testARefusedArmKeepsTheMarker() + + /** + * A term that passed while nothing watched it is enforced as the fired timer would have: the item is terminated. + * + * @return void + */ + public function testATermThatPassedMeanwhileTerminatesTheItem(): void { + $row = $this->markedItem(expires: '-1 hour'); + $this->timerRows->method('findBySubject')->willReturn([]); + $this->core->expects($this->never())->method('arm'); + $this->plans->expects($this->once())->method('onTimerExpired')->with((string)$row->getUuid(), CasePlanTimerRearm::CAUSE_REF)->willReturn(true); + + $result = $this->pass()->run(now: new DateTimeImmutable()); + + $this->assertSame(['armed' => 0, 'enforced' => 1, 'dropped' => 0, 'failed' => 0], $result); + $this->assertSame([], $this->markers->all()); + }//end testATermThatPassedMeanwhileTerminatesTheItem() + + /** + * Nothing is armed for an item that is gone, no longer active, or already has a running expiry timer. + * + * @return void + */ + public function testAnItemThatNeedsNothingLosesItsMarkerWithoutArming(): void { + $this->core->expects($this->never())->method('arm'); + $this->plans->expects($this->never())->method('onTimerExpired'); + $this->markers->add(itemUuid: 'gone'); + $done = $this->markedItem(state: CaseItem::STATE_COMPLETED); + $this->timerRows->method('findBySubject')->willReturn([]); + + $result = $this->pass()->run(now: new DateTimeImmutable()); + $this->assertSame(['armed' => 0, 'enforced' => 0, 'dropped' => 2, 'failed' => 0], $result); + $this->assertSame([], $this->markers->all()); + + // An active item whose timer was armed after all. + $this->setUp(); + $this->core->expects($this->never())->method('arm'); + $this->markedItem(); + $running = new FlowTimer(); + $running->setPurpose(FlowTimer::PURPOSE_EXPIRY); + $this->timerRows->method('findBySubject')->willReturn([$running]); + $this->assertSame(1, $this->pass()->run(now: new DateTimeImmutable())['dropped']); + $this->assertSame([], $this->markers->all()); + $this->assertSame(CaseItem::STATE_COMPLETED, $done->getState()); + }//end testAnItemThatNeedsNothingLosesItsMarkerWithoutArming() + + /** + * The marker keeps the first moment, and unreadable stored values never break a read. + * + * @return void + */ + public function testTheMarkerStoreKeepsTheFirstMomentAndToleratesGarbage(): void { + $this->markers->add(itemUuid: 'item-a', now: new DateTimeImmutable('2026-10-01T10:00:00+00:00')); + $this->markers->add(itemUuid: 'item-a', now: new DateTimeImmutable('2026-10-02T10:00:00+00:00')); + $this->assertSame(['item-a' => '2026-10-01T10:00:00+00:00'], $this->markers->all()); + + $this->stored = 'not json'; + $this->assertSame([], $this->markers->all()); + $this->stored = '{"item-b":"2026-10-01T10:00:00+00:00","":"x","item-c":5}'; + $this->assertSame(['item-b' => '2026-10-01T10:00:00+00:00'], $this->markers->all()); + }//end testTheMarkerStoreKeepsTheFirstMomentAndToleratesGarbage() +}//end class diff --git a/tests/Unit/Service/Case/CaseRealisationServiceTest.php b/tests/Unit/Service/Case/CaseRealisationServiceTest.php index 8131b076fb..a8496b47d3 100644 --- a/tests/Unit/Service/Case/CaseRealisationServiceTest.php +++ b/tests/Unit/Service/Case/CaseRealisationServiceTest.php @@ -27,6 +27,7 @@ use OCA\OpenRegister\Db\FlowRunMapper; use OCA\OpenRegister\Db\Task; use OCA\OpenRegister\Db\TaskMapper; +use OCA\OpenRegister\Service\Case\CasePlanService; use OCA\OpenRegister\Service\Case\CaseRealisationService; use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Task\TaskService; @@ -183,6 +184,32 @@ public function testAFlowBoundStageQueuesARunAndOthersRealiseNothing(): void { $this->assertNull($plain->getRealisationUuid()); }//end testAFlowBoundStageQueuesARunAndOthersRealiseNothing() + /** + * A plan created by an app's in-process code acts as `system:`, which names no account: a flow-bound + * stage or process task queues its run without a caller, so the run is not `runAs` that label and the flow's own + * attribution decides. A real caller is still passed through. + * + * @return void + */ + public function testARunForASystemCreatedPlanIsQueuedWithoutTheSystemLabelAsItsUser(): void { + $task = CaseFixtures::row(id: 3, key: 'verwerk', type: CaseItem::TYPE_PROCESS_TASK, state: CaseItem::STATE_AVAILABLE); + $task->setPlanSettings(['flows' => ['verwerk' => 'flow-9']]); + $run = new FlowRun(); + $run->setUuid('run-9'); + $this->runs->expects($this->once())->method('queue')->with( + 'flow-9', + $this->anything(), + CaseRealisationService::RUN_TRIGGER, + ['caseItem' => 'item-3'], + null + )->willReturn($run); + + $this->service()->realise(item: $task, actor: CasePlanService::SYSTEM_ACTOR_PREFIX . 'dossiq'); + + $this->assertSame(CaseItem::REALISATION_RUN, $task->getRealisationKind()); + $this->assertSame('run-9', $task->getRealisationUuid()); + }//end testARunForASystemCreatedPlanIsQueuedWithoutTheSystemLabelAsItsUser() + /** * Terminal outcomes: task completed/terminated, run completed/stopped, * open = null, missing = terminated, none = null. diff --git a/tests/Unit/Service/Case/Nodes/CaseFlowNodesTest.php b/tests/Unit/Service/Case/Nodes/CaseFlowNodesTest.php index d6ff82f75d..b389547312 100644 --- a/tests/Unit/Service/Case/Nodes/CaseFlowNodesTest.php +++ b/tests/Unit/Service/Case/Nodes/CaseFlowNodesTest.php @@ -280,6 +280,61 @@ public function testAdvanceMovesTheItemAndRefusalsFailTheStep(): void { $node->validateConfig(config: ['item' => 'besluit-genomen', 'to' => 'done']); }//end testAdvanceMovesTheItemAndRefusalsFailTheStep() + /** + * A configured `uuid` that names ANOTHER object than the received item + * must not anchor that object's plan to the item's register and schema; + * naming the item's own object keeps them. + * + * @return void + */ + public function testAConfiguredUuidNamingAnotherObjectIsNotAnchoredToTheItemsRegisterAndSchema(): void { + $node = new CaseOpenNode(...$this->collaborators()); + $context = [FlowRunService::RUN_AS_CONTEXT_KEY => 'alice']; + $other = CaseFixtures::OBJECT; + $items = [[FlowItems::JSON => ['uuid' => 'received-object-of-schema-a', 'parent' => $other, '@self' => ['register' => '1', 'schema' => 2]]]]; + + $out = $node->execute(items: $items, config: ['definition' => self::definition(), 'uuid' => '{{ parent }}'], context: $context); + + $this->assertSame(['opened' => true, 'reason' => 'opened'], $out[0][FlowItems::JSON]['case']); + $rows = $this->items->findByObject(objectUuid: $other); + $this->assertCount(2, $rows); + $this->assertNull($rows[0]->getRegisterId(), 'the item\'s register is not the other object\'s'); + $this->assertNull($rows[0]->getSchemaId(), 'the item\'s schema is not the other object\'s'); + + // A template that renders the item's own uuid still anchors to its register and schema. + $ownItems = [[FlowItems::JSON => ['uuid' => 'own-object', 'again' => 'own-object', '@self' => ['register' => '1', 'schema' => 2]]]]; + $node->execute(items: $ownItems, config: ['definition' => self::definition(), 'uuid' => '{{ again }}'], context: $context); + $own = $this->items->findByObject(objectUuid: 'own-object'); + $this->assertSame(1, $own[0]->getRegisterId()); + $this->assertSame(2, $own[0]->getSchemaId()); + }//end testAConfiguredUuidNamingAnotherObjectIsNotAnchoredToTheItemsRegisterAndSchema() + + /** + * A repeating item has one row per realisation, the oldest first in plan + * order. Advancing its key moves the realisation that is still open, not + * the finished first one the old first-match loop stopped at. + * + * @return void + */ + public function testAdvanceMovesTheLiveRealisationOfARepeatingItem(): void { + $first = CaseFixtures::row(id: 1, key: 'assessment', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_COMPLETED); + $second = CaseFixtures::row(id: 2, key: 'assessment', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE); + $second->setRealisationCount(2); + $second->setPosition(1); + $first->setPosition(1); + $this->items->seed([$first, $second]); + + $this->sessionUid = 'alice'; + $node = new CaseAdvanceNode(...$this->collaborators()); + $out = $node->execute(items: self::items(), config: ['item' => 'assessment', 'to' => CaseItem::STATE_COMPLETED], context: []); + + $this->assertSame(['item' => 'assessment', 'state' => 'completed'], $out[0][FlowItems::JSON]['case']); + $this->assertSame(CaseItem::STATE_COMPLETED, $this->items->findByUuid('item-2')->getState(), 'the open realisation moved'); + $this->assertSame(CaseItem::STATE_COMPLETED, $this->items->findByUuid('item-1')->getState()); + $this->assertCount(1, $this->audits->findForItem(2), 'the transition is recorded against the second realisation'); + $this->assertCount(0, $this->audits->findForItem(1), 'and not against the first'); + }//end testAdvanceMovesTheLiveRealisationOfARepeatingItem() + /** * An empty batch passes straight through both nodes; an advance with no * item key is refused at save time. diff --git a/tests/Unit/Service/Configuration/ImportHandlerSeedObjectUpgradeTest.php b/tests/Unit/Service/Configuration/ImportHandlerSeedObjectUpgradeTest.php index 563c6b283f..d478e00c67 100644 --- a/tests/Unit/Service/Configuration/ImportHandlerSeedObjectUpgradeTest.php +++ b/tests/Unit/Service/Configuration/ImportHandlerSeedObjectUpgradeTest.php @@ -83,8 +83,11 @@ protected function setUp(): void { $this->appConfig = $this->createMock(IAppConfig::class); $this->objectService = $this->createMock(ObjectService::class); - // Nothing stored yet: no version, no hash, so the import runs. - $this->appConfig->method('getValueString')->willReturn(''); + // Nothing stored yet: no version, no hash, so the import runs. What the import writes reads back, so a + // second import sees the shipped versions the first one remembered. + $this->appConfig->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = ''): string => ($this->configWrites[$key] ?? '') + ); $this->appConfig->method('setValueString')->willReturnCallback( function (string $app, string $key, string $value): bool { $this->configWrites[$key] = $value; @@ -292,6 +295,64 @@ public function testAValidationFailureKeepsTheHash(): void { $this->assertNotSame('', ($this->configWrites['imported_config_dossiq_hash'] ?? '')); }//end testAValidationFailureKeepsTheHash() + /** + * 🔴 A seed the instance edited once still receives the next shipped version. + * + * Imported at 1.4.7, the stored object's own version moves with every later + * save: one edit reads 1.4.8. The file then ships 1.4.8, which compares equal + * against the object's own version and would never arrive. The import + * compares against the version it last SHIPPED (1.4.7), so it does. + * + * @return void + */ + public function testASeedEditedOnTheInstanceStillReceivesTheNextShippedVersion(): void { + $this->instanceHoldsCaseType(storedVersion: '1.4.8'); + $this->configWrites['imported_seed_versions_dossiq'] = (string)json_encode(['23/165/woo-verzoek' => '1.4.7']); + $this->saves(static fn (int $attempt): ?\Throwable => null); + + $result = $this->importDossiq(shippedVersion: '1.4.8'); + + $this->assertSame(0, $result['unchanged']['objects']); + $this->assertSame( + [['slug' => 'woo-verzoek', 'uuid' => self::CASE_TYPE_UUID, 'written' => true], ['slug' => 'woo-verzoek-flow', 'uuid' => self::FLOW_UUID, 'written' => true]], + $this->saves, + 'the shipped values arrive although the stored object reads the same version' + ); + $this->assertSame('1.4.8', json_decode($this->configWrites['imported_seed_versions_dossiq'], true)['23/165/woo-verzoek'], 'and the new shipped version is remembered'); + }//end testASeedEditedOnTheInstanceStillReceivesTheNextShippedVersion() + + /** + * A seed stored before the import remembered anything is read as it was: the stored version is the baseline, + * and the version the file ships is remembered from then on. + * + * @return void + */ + public function testASeedStoredBeforeTheLedgerExistedIsRememberedAtTheShippedVersion(): void { + $this->instanceHoldsCaseType(storedVersion: '1.4.7'); + $this->saves(static fn (int $attempt): ?\Throwable => null); + + $result = $this->importDossiq(shippedVersion: '1.4.7'); + + $this->assertSame(1, $result['unchanged']['objects']); + $this->assertSame('1.4.7', json_decode($this->configWrites['imported_seed_versions_dossiq'], true)['23/165/woo-verzoek']); + }//end testASeedStoredBeforeTheLedgerExistedIsRememberedAtTheShippedVersion() + + /** + * A seed at the shipped version is left alone even when edited: the shipped version has not moved. + * + * @return void + */ + public function testAnEditedSeedIsLeftAloneWhileTheShippedVersionHasNotMoved(): void { + $this->instanceHoldsCaseType(storedVersion: '1.4.9'); + $this->configWrites['imported_seed_versions_dossiq'] = (string)json_encode(['23/165/woo-verzoek' => '1.4.7']); + $this->saves(static fn (int $attempt): ?\Throwable => null); + + $result = $this->importDossiq(shippedVersion: '1.4.7'); + + $this->assertSame(1, $result['unchanged']['objects']); + $this->assertSame([['slug' => 'woo-verzoek-flow', 'uuid' => self::FLOW_UUID, 'written' => true]], $this->saves); + }//end testAnEditedSeedIsLeftAloneWhileTheShippedVersionHasNotMoved() + /** * A stored seed at the shipped version is left alone: the instance's own data is not rewritten. * 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 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 diff --git a/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php b/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php index fdb75289c4..43bc59d13d 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,126 @@ 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 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))); - $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']); + 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 testAnOversizedUpdateIsKeptWithoutItsOldData() + + /** + * 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. * diff --git a/tests/Unit/Service/Dmn/DecisionTableStoreTest.php b/tests/Unit/Service/Dmn/DecisionTableStoreTest.php index 764137ec8d..27ef4388e5 100644 --- a/tests/Unit/Service/Dmn/DecisionTableStoreTest.php +++ b/tests/Unit/Service/Dmn/DecisionTableStoreTest.php @@ -77,6 +77,20 @@ public function testATableIsFoundByKey(): void { $this->assertSame('Parcel carrier', $store->find('parcel-carrier')['name']); } + /** + * Two rows with one key (written around the save guard) are two tables in no fixed order: the lookup refuses + * rather than answering with whichever the register returned first. + * + * @return void + */ + public function testTwoTablesWithOneKeyAreRefusedNotPickedFrom(): void { + $store = $this->store([['u-1', self::shippingTable()], ['u-2', self::shippingTable(carrier: 'air')]]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('More than one decision table has the key "parcel-carrier" (u-1, u-2)'); + $store->find('parcel-carrier'); + }//end testTwoTablesWithOneKeyAreRefusedNotPickedFrom() + public function testATableIsFoundByUuid(): void { $store = $this->store([['u-1', self::shippingTable()]]); $this->assertSame('parcel-carrier', $store->find('u-1')['key']); diff --git a/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php b/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php index 5e0ba5b839..a48cdbab47 100644 --- a/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php +++ b/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php @@ -197,10 +197,36 @@ public function testASensitiveKeyIsRefusedUnlessTheSetGeneratesIt(): void { $this->assertTrue($report['ok'], json_encode($report)); $this->assertTrue($this->sensitive['alpha']['secret']); $this->assertStringNotContainsString($this->appValues['alpha']['secret'], (string) json_encode($report).json_encode($this->config)); + $this->assertStringNotContainsString($this->appValues['alpha']['secret'], $this->config['example_set_demo'], 'the record holds a fingerprint, never the value'); $this->remover->remove(set: 'demo'); $this->assertArrayNotHasKey('secret', $this->appValues['alpha']); } + public function testAGeneratedKeyAnAdministratorReplacedStaysAndIsNamed(): void { + $this->alpha(['appConfig' => [['key' => 'secret', 'generate' => true]]]); + $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today()); + + // The administrator puts a real token there after the install. + $this->appValues['alpha']['secret'] = 'their-real-api-token'; + $removed = $this->remover->remove(set: 'demo'); + + $this->assertSame('their-real-api-token', $this->appValues['alpha']['secret']); + $this->assertContains('app config alpha secret, which somebody changed after the install', $removed['left']); + $this->assertSame([], $removed['failed']); + } + + public function testAGeneratedKeyThatHeldAnEmptyValueGetsItBack(): void { + $this->appValues['alpha'] = ['secret' => '']; + $this->alpha(['appConfig' => [['key' => 'secret', 'generate' => true]]]); + $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today()); + $this->assertNotSame('', $this->appValues['alpha']['secret']); + + $this->remover->remove(set: 'demo'); + + $this->assertArrayHasKey('secret', $this->appValues['alpha'], 'a key that was there stays there'); + $this->assertSame('', $this->appValues['alpha']['secret']); + } + public function testTheInstanceAddressFillsAPlaceholderAndIsNeverEmpty(): void { $this->alpha(['objects' => [['key' => 'pub-1', 'register' => 'pub', 'schema' => 'publication', 'data' => ['url' => '{{instance:baseUrl}}/apps/x']]]]); $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today()); @@ -276,6 +302,29 @@ public function testTheCastsOrganisationIsCreatedOnceFoundByPlaceholderAndRemove $this->assertSame(['org-existing'], array_keys($this->organisations)); } + public function testAnOrganisationWithAMemberTheSetDidNotAddSurvivesRemoveAndIsNamed(): void { + $file = $this->castFile(); + $file['cast']['openregisterOrganisations'] = [['key' => 'gemeente', 'name' => 'Gemeente Demo', 'slug' => 'demo']]; + $this->app(app: 'openregister', fragment: $file); + $this->alpha(['objects' => [['key' => 'pub-1', 'register' => 'pub', 'schema' => 'publication', 'data' => ['organization' => '{{organisation:demo}}']]]]); + $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today()); + $this->accounts->users['real.person'] = ['displayName' => 'Real Person', 'email' => '', 'password' => 'x']; + $this->organisations['org-1']->setUsers(['sanne.devries', 'real.person']); + + $removed = $this->remover->remove(set: 'demo'); + + $this->assertSame(0, $removed['removed']['organisations']); + $this->assertCount(1, $this->organisations, 'a tenant somebody joined is not deleted'); + $this->assertSame(['OpenRegister organisation org-1, which has members the set did not add'], $removed['left']); + $this->assertSame([], $removed['failed']); + $this->assertSame(['org-1'], $this->record->read(set: 'demo')['organisations'], 'it stays in the record'); + + unset($this->accounts->users['real.person']); + $again = $this->remover->remove(set: 'demo'); + $this->assertSame(1, $again['removed']['organisations'], 'once only its own accounts and leftovers of deleted ones are members it goes'); + $this->assertSame([], $this->organisations); + } + public function testEveryCastMemberHasAStableContactsUid(): void { $plan = $this->catalogue->gather(set: 'demo'); diff --git a/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php b/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php index 0dd27201c6..bbcf3d8d14 100644 --- a/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php +++ b/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php @@ -86,6 +86,61 @@ public function testInstallWritesTheCastAndEveryFragmentAndProvesIt(): void { $this->assertStringNotContainsString('generated-', $this->config['example_set_demo'], 'a password is never in the record'); } + public function testAReadErrorOnAKeptObjectWritesNothingAndKeepsTheRecordedId(): void { + $plan = $this->catalogue->gather(set: 'demo'); + $this->installer->install(plan: $plan, today: $this->today()); + $before = json_decode($this->config['example_set_demo'], true); + $caseId = $before['objects']['cases/case-1']['id']; + $creates = $this->store->creates; + + // The database cannot be read right now: the object is on the instance, but the read says nothing. + $this->store->unreadable = [$caseId]; + $report = $this->installer->install(plan: $plan, today: $this->today()); + + $this->assertSame($creates, $this->store->creates, 'nothing is written again'); + $after = json_decode($this->config['example_set_demo'], true); + $this->assertSame($caseId, $after['objects']['cases/case-1']['id'], 'the original stays in the record'); + $this->assertSame($before['objects'], $after['objects']); + $this->assertFalse($report['ok']); + $this->assertStringContainsString('could not be read (database is gone)', implode("\n", $report['dropped'])); + + // Once it can be read again the next install finds it and writes nothing. + $this->store->unreadable = []; + $this->assertTrue($this->installer->install(plan: $plan, today: $this->today())['ok']); + $this->assertSame($creates, $this->store->creates); + } + + public function testTheRecordIsALazyValueAndAnEarlierNonLazyRecordIsStillFound(): void { + $plan = $this->catalogue->gather(set: 'demo'); + $this->installer->install(plan: $plan, today: $this->today()); + + $this->assertTrue($this->lazyKeys['example_set_demo'], 'the record stays out of every request'); + + // A record an earlier version stored as a non-lazy value is read, and is lazy again after the next write. + $this->lazyKeys['example_set_demo'] = false; + $creates = $this->store->creates; + $this->assertSame(['sanne.devries' => 'kept', 'esra.demir' => 'kept'], $this->installer->install(plan: $plan, today: $this->today())['users']); + $this->assertSame($creates, $this->store->creates, 'the old record is found, so nothing is written twice'); + + $this->remover->remove(set: 'demo'); + $this->assertSame([], $this->config); + } + + public function testALookupThatTwoRowsAnswerRefusesTheCheckAndWritesNothing(): void { + // Another organisation holds a case type with the same business key. + $this->store->seed(register: 'cases', schema: 'caseType', data: ['identifier' => 'woo']); + $plan = $this->catalogue->gather(set: 'demo'); + + $check = $this->installer->check(plan: $plan, today: $this->today()); + + $this->assertCount(1, $check['refusals']); + $this->assertStringContainsString('2 cases caseType rows with identifier "woo"; a lookup needs exactly one', $check['refusals'][0]); + + $report = $this->installer->install(plan: $plan, today: $this->today()); + $this->assertCount(1, $report['refusals']); + $this->assertSame(0, $this->store->creates, 'a refused set writes nothing'); + } + public function testASecondInstallWritesNothingAndSaysAlreadyThere(): void { $plan = $this->catalogue->gather(set: 'demo'); $this->installer->install(plan: $plan, today: $this->today()); diff --git a/tests/Unit/Service/ExampleSet/ExampleSetInstanceTest.php b/tests/Unit/Service/ExampleSet/ExampleSetInstanceTest.php new file mode 100644 index 0000000000..50794a01b0 --- /dev/null +++ b/tests/Unit/Service/ExampleSet/ExampleSetInstanceTest.php @@ -0,0 +1,84 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://www.OpenRegister.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\ExampleSet; + +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\OpenRegister\Service\ExampleSet\ExampleSetInstance; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IConfig; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use RuntimeException; + +/** + * @covers \OCA\OpenRegister\Service\ExampleSet\ExampleSetInstance + */ +class ExampleSetInstanceTest extends TestCase { + /** @var OrganisationMapper&MockObject */ + private $mapper; + + protected function setUp(): void { + parent::setUp(); + $this->mapper = $this->createMock(OrganisationMapper::class); + } + + private function instance(): ExampleSetInstance { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($this->mapper); + + return new ExampleSetInstance(container: $container, config: $this->createMock(IConfig::class)); + } + + public function testALookupThatFailsIsNotReadAsNoOrganisationAndNothingIsInserted(): void { + $this->mapper->method('findBySlug')->willThrowException(new RuntimeException('database is gone')); + $this->mapper->expects($this->never())->method('insert'); + + $result = $this->instance()->ensure(declared: ['name' => 'Gemeente', 'slug' => 'demo', 'description' => '']); + + $this->assertSame(['uuid' => '', 'created' => false, 'error' => 'database is gone'], $result); + } + + public function testAnOrganisationThatDoesNotExistIsCreated(): void { + $this->mapper->method('findBySlug')->willThrowException(new DoesNotExistException('none')); + $saved = new Organisation(); + $saved->setUuid('org-new'); + $this->mapper->expects($this->once())->method('insert')->willReturn($saved); + + $result = $this->instance()->ensure(declared: ['name' => 'Gemeente', 'slug' => 'demo', 'description' => '']); + + $this->assertSame(['uuid' => 'org-new', 'created' => true, 'error' => ''], $result); + } + + public function testALookupThatFailsIsNotReadAsGoneAndNothingIsForgotten(): void { + $this->mapper->method('findByUuid')->willThrowException(new RuntimeException('database is gone')); + $this->mapper->expects($this->never())->method('delete'); + + $this->assertSame(ExampleSetInstance::FAILED, $this->instance()->delete(uuid: 'org-1')); + } + + public function testAnOrganisationThatDoesNotExistIsGone(): void { + $this->mapper->method('findByUuid')->willThrowException(new DoesNotExistException('none')); + + $this->assertSame(ExampleSetInstance::DELETED, $this->instance()->delete(uuid: 'org-1')); + } +} diff --git a/tests/Unit/Service/ExampleSet/ExampleSetMockRegisterImportTest.php b/tests/Unit/Service/ExampleSet/ExampleSetMockRegisterImportTest.php index 05ea1782fb..ffeae392ae 100644 --- a/tests/Unit/Service/ExampleSet/ExampleSetMockRegisterImportTest.php +++ b/tests/Unit/Service/ExampleSet/ExampleSetMockRegisterImportTest.php @@ -89,9 +89,11 @@ function (string $appId, array $data, string $version, bool $force = false): arr $this->instance[strtolower((string) $slug)] = $version; } - $this->recorded[$appId][] = ['jobId' => 'job-'.count($this->importedAs), 'version' => $version, 'created' => 1, 'importedAt' => '']; + $jobId = 'job-'.count($this->importedAs); + $this->recorded[$appId][] = ['jobId' => $jobId, 'version' => $version, 'created' => 1, 'importedAt' => '']; - return ['objects' => array_keys((array) ($data['components']['objects'] ?? [])), 'skipped' => []]; + // As ImportHandler does: the result names the job the import ran under. + return ['objects' => array_keys((array) ($data['components']['objects'] ?? [])), 'skipped' => [], 'importJobId' => $jobId]; } ); diff --git a/tests/Unit/Service/ExampleSet/ExampleSetReferencesTest.php b/tests/Unit/Service/ExampleSet/ExampleSetReferencesTest.php index 0eba1b8c1e..b2d993292f 100644 --- a/tests/Unit/Service/ExampleSet/ExampleSetReferencesTest.php +++ b/tests/Unit/Service/ExampleSet/ExampleSetReferencesTest.php @@ -120,4 +120,49 @@ public function testAnUntouchedSecondInstallRefreshesNothing(): void { $this->assertSame($updates, $this->store->updates); $this->assertSame([], $this->record->read(set: 'demo')['changes']); } + + /** + * A user who pointed a kept object's reference at something else keeps that edit: the next install leaves it and says so. + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-kept-object-follows-the-objects-it-references-req-exs-023 + */ + public function testAUsersEditOfAKeptObjectsReferenceIsLeftAndNamed(): void { + $publications = $this->store->ids(register: 'pub', schema: 'publication'); + $this->store->rows['pub publication'][$publications[0]]['themes'] = ['an-unrelated-id']; + $updates = $this->store->updates; + + $report = $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today()); + + $this->assertSame(['an-unrelated-id'], $this->store->get(register: 'pub', schema: 'publication', id: $publications[0])['themes']); + $this->assertSame($updates, $this->store->updates, 'nothing is written over the edit'); + $this->assertArrayNotHasKey('alpha/pub-1#references', $report['changes']); + $this->assertArrayNotHasKey('alpha/pub-1#references', $this->record->read(set: 'demo')['changes']); + $this->assertSame(['lib/Settings/example-sets/demo.json: object "pub-1": field "themes" was changed after the install and is left as it is'], array_map( + static fn (string $line): string => preg_replace('#^[^:]*/lib/#', 'lib/', $line), + $report['left'] + )); + $this->assertTrue($report['ok'], 'an edit is not a failure'); + } + + /** + * A kept object whose record predates the recorded values is not refreshed over a value nobody can vouch for. + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-kept-object-follows-the-objects-it-references-req-exs-023 + */ + public function testAReferenceTheRecordHoldsNoValueForIsLeft(): void { + $record = $this->record->read(set: 'demo'); + foreach ($record['objects'] as $name => $held) { + unset($record['objects'][$name]['refs']); + } + + $this->record->write(set: 'demo', record: $record); + $this->store->undeletable = $this->store->ids(register: 'pub', schema: 'publication'); + $this->remover->remove(set: 'demo'); + $this->store->undeletable = []; + + $report = $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today()); + + $this->assertCount(2, $report['left']); + $this->assertArrayNotHasKey('alpha/pub-1#references', $report['changes']); + } } diff --git a/tests/Unit/Service/ExampleSet/ExampleSetRegistersTest.php b/tests/Unit/Service/ExampleSet/ExampleSetRegistersTest.php new file mode 100644 index 0000000000..024728cbe0 --- /dev/null +++ b/tests/Unit/Service/ExampleSet/ExampleSetRegistersTest.php @@ -0,0 +1,113 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://www.OpenRegister.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\ExampleSet; + +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Service\Configuration\AppImportJobRecorder; +use OCA\OpenRegister\Service\ExampleSet\ExampleSetRegisters; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\RegisterDescriptorService; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use RuntimeException; + +/** + * @covers \OCA\OpenRegister\Service\ExampleSet\ExampleSetRegisters + */ +class ExampleSetRegistersTest extends TestCase { + /** + * A registers adapter over a container that hands out the given services. + * + * @param array $services The services by class. + * + * @return ExampleSetRegisters + */ + private function registersOver(array $services): ExampleSetRegisters { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + static fn (string $id): object => ($services[$id] ?? throw new RuntimeException('no service '.$id)) + ); + + return new ExampleSetRegisters(container: $container); + } + + /** + * The job is the one the import reports under the id it ran under, and the instance's other jobs are never read: + * a job another process records meanwhile cannot be taken for the set's. + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009 + */ + public function testTheImportClaimsExactlyTheJobItReports(): void { + $descriptors = $this->createMock(RegisterDescriptorService::class); + $descriptors->expects($this->once())->method('reimport')->willReturn( + ['outcome' => 'imported', 'importJobId' => 'job-mine', 'importAppId' => 'openregister.vocabulary'] + ); + $recorder = $this->createMock(AppImportJobRecorder::class); + $recorder->expects($this->never())->method('jobs'); + + $result = $this->registersOver([RegisterDescriptorService::class => $descriptors, AppImportJobRecorder::class => $recorder])->import(slug: 'brp'); + + $this->assertSame(['error' => '', 'jobs' => [['appId' => 'openregister.vocabulary', 'jobId' => 'job-mine']]], $result); + } + + /** + * An import that recorded no job claims none, and a failed one says why. + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009 + */ + public function testAnImportWithoutAJobClaimsNothingAndAFailureIsNamed(): void { + $descriptors = $this->createMock(RegisterDescriptorService::class); + $descriptors->method('reimport')->willReturnOnConsecutiveCalls( + ['outcome' => 'imported'], + ['outcome' => 'failed', 'reason' => 'no descriptor'] + ); + $registers = $this->registersOver([RegisterDescriptorService::class => $descriptors]); + + $this->assertSame(['error' => '', 'jobs' => []], $registers->import(slug: 'brp')); + $this->assertSame(['error' => 'no descriptor', 'jobs' => []], $registers->import(slug: 'kvk')); + } + + /** + * Remove soft-deletes the objects of the recorded job and forgets it only when all of them went. + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009 + */ + public function testRemoveImportForgetsTheJobOnlyWhenEveryObjectWent(): void { + $audit = $this->createMock(AuditTrailMapper::class); + $audit->method('objectUuidsByImportJobId')->willReturn(['u1', 'u2']); + $refuse = true; + $objects = $this->createMock(ObjectService::class); + $objects->method('deleteObject')->willReturnCallback(static function (string $uuid) use (&$refuse): bool { + if ($uuid === 'u2' && $refuse === true) { + throw new RuntimeException('refused'); + } + + return true; + }); + $recorder = $this->createMock(AppImportJobRecorder::class); + $recorder->expects($this->once())->method('forget')->with('openregister.vocabulary', 'job-mine'); + $registers = $this->registersOver([AuditTrailMapper::class => $audit, ObjectService::class => $objects, AppImportJobRecorder::class => $recorder]); + + $this->assertFalse($registers->removeImport(appId: 'openregister.vocabulary', jobId: 'job-mine'), 'a refused object leaves the job in place'); + $refuse = false; + $this->assertTrue($registers->removeImport(appId: 'openregister.vocabulary', jobId: 'job-mine')); + } +} diff --git a/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php b/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php index 5520ca97cd..4185764fe4 100644 --- a/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php +++ b/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php @@ -60,7 +60,29 @@ public function testAMissingRequiredRegisterIsImportedBeforeTheCheckAndRemovedAf $removed = $this->remover->remove(set: 'demo'); $this->assertSame(1, $removed['removed']['register imports']); $this->assertSame([], $this->registers->jobs); - $this->assertSame([], $this->config); + $this->assertSame(['example_set_demo' => '{"registers":{"brp":[]}}'], $this->config, 'only the memory that the register\'s data was removed'); + $this->assertTrue($this->record->isEmpty(record: $this->record->read(set: 'demo')), 'which is not an install'); + } + + public function testARegisterTheSetImportedIsImportedAgainAfterARemove(): void { + $this->store->offer(register: 'brp', schema: 'person'); + $this->registers->importable['brp'] = true; + $this->app(app: 'openregister', fragment: $this->castRequiringBrp()); + $plan = $this->catalogue->gather(set: 'demo'); + $this->installer->install(plan: $plan, today: $this->today()); + $this->remover->remove(set: 'demo'); + $this->assertSame([], $this->registers->jobs, 'the data of the first import is gone'); + $this->assertTrue($this->registers->exists(slug: 'brp'), 'the register and its schemas stay'); + + $second = $this->installer->install(plan: $plan, today: $this->today()); + + $this->assertTrue($second['ok'], json_encode($second)); + $this->assertSame(2, $this->registers->imports, 'the data is imported again, not skipped because the register exists'); + $this->assertCount(1, $this->registers->jobs); + + $removed = $this->remover->remove(set: 'demo'); + $this->assertSame(1, $removed['removed']['register imports']); + $this->assertSame([], $this->registers->jobs); } public function testARegisterThatWasAlreadyThereIsNeitherImportedNorRemoved(): void { @@ -166,6 +188,28 @@ public function testAChangeIsMadeOnceProvedAndRevertedExactly(): void { ); } + public function testAPathSomebodyEditedAfterTheInstallIsLeftAndNamedOnRemove(): void { + $this->store->offer(register: 'site', schema: 'portal'); + $portal = $this->store->seed(register: 'site', schema: 'portal', data: ['slug' => 'demo', 'title' => 'Demo', 'authentication' => ['modes' => ['password']]]); + $this->app(app: 'openregister', fragment: $this->castFile()); + $this->app(app: 'site', fragment: $this->signInChange()); + $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today()); + + // A user rewrites the card the set wrote; the appended mode is still the set's. + $this->store->rows['site portal'][$portal]['authentication']['modeLabels']['nextcloud'] = ['title' => 'Mijn eigen kaart']; + $removed = $this->remover->remove(set: 'demo'); + + $this->assertSame([], $removed['failed']); + $this->assertSame(1, $removed['removed']['changes']); + $this->assertSame( + ['change site/sign-in on site portal ('.$portal.'), path authentication.modeLabels.nextcloud, which somebody changed after the install'], + $removed['left'] + ); + $row = $this->store->get(register: 'site', schema: 'portal', id: $portal); + $this->assertSame(['title' => 'Mijn eigen kaart'], $row['authentication']['modeLabels']['nextcloud'], 'the user\'s value stays'); + $this->assertSame(['password'], $row['authentication']['modes'], 'what the set appended is still taken out'); + } + public function testAChangeThatIsAlreadyThereIsNotRecordedAndSurvivesRemove(): void { $this->store->offer(register: 'site', schema: 'portal'); $portal = $this->store->seed( @@ -183,6 +227,27 @@ public function testAChangeThatIsAlreadyThereIsNotRecordedAndSurvivesRemove(): v $this->assertSame(['nextcloud'], $this->store->get(register: 'site', schema: 'portal', id: $portal)['authentication']['modes']); } + public function testAChangeWhoseWhereTwoObjectsAnswerRefusesTheCheckAndWritesNothing(): void { + $this->store->offer(register: 'site', schema: 'portal'); + $first = $this->store->seed(register: 'site', schema: 'portal', data: ['slug' => 'demo', 'authentication' => ['modes' => ['password']]]); + $second = $this->store->seed(register: 'site', schema: 'portal', data: ['slug' => 'demo', 'authentication' => ['modes' => ['password']]]); + $this->app(app: 'openregister', fragment: $this->castFile()); + $this->app(app: 'site', fragment: $this->signInChange()); + $plan = $this->catalogue->gather(set: 'demo'); + + $check = $this->installer->check(plan: $plan, today: $this->today()); + $this->assertSame( + ['site/lib/Settings/example-sets/demo.json: change "sign-in" (site portal): 2 site portal rows match {"slug":"demo"}; a change needs exactly one to change'], + $check['refusals'] + ); + + $report = $this->installer->install(plan: $plan, today: $this->today()); + $this->assertCount(1, $report['refusals']); + $this->assertSame(0, $this->store->updates); + $this->assertSame(['password'], $this->store->get(register: 'site', schema: 'portal', id: $first)['authentication']['modes']); + $this->assertSame(['password'], $this->store->get(register: 'site', schema: 'portal', id: $second)['authentication']['modes']); + } + public function testAChangeWithoutItsObjectRefusesTheCheck(): void { $this->store->offer(register: 'site', schema: 'portal'); $this->app(app: 'openregister', fragment: $this->castFile()); diff --git a/tests/Unit/Service/ExampleSet/ExampleSetStoreTest.php b/tests/Unit/Service/ExampleSet/ExampleSetStoreTest.php index a792c410a6..fbc071948b 100644 --- a/tests/Unit/Service/ExampleSet/ExampleSetStoreTest.php +++ b/tests/Unit/Service/ExampleSet/ExampleSetStoreTest.php @@ -27,6 +27,7 @@ use OCA\OpenRegister\Service\ExampleSet\ExampleSetStore; use OCA\OpenRegister\Service\Object\ValidateObject; use OCA\OpenRegister\Service\ObjectService; +use OCP\AppFramework\Db\DoesNotExistException; use Opis\JsonSchema\ValidationResult; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -142,7 +143,7 @@ public function testARefusalIsReturnedAsTheReasonAndAGoneRowIsGone(): void { $objects = $this->createMock(ObjectService::class); $objects->method('find')->willReturnCallback(static function (mixed $id) use ($row): ObjectEntity { if ($id === 'gone') { - throw new \RuntimeException('not found'); + throw new DoesNotExistException('Object gone not found'); } return $row; @@ -153,4 +154,42 @@ public function testARefusalIsReturnedAsTheReasonAndAGoneRowIsGone(): void { $this->assertSame('a relation still points here', $store->deleteRecorded(register: 'pipelinq', schema: 'ticket', id: 'ticket-1')); $this->assertSame('', $store->deleteRecorded(register: 'pipelinq', schema: 'ticket', id: 'gone')); } + + /** + * A read that fails says nothing about the row: remove must not drop it from the record as gone. + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-remove-deletes-exactly-what-the-install-made-req-exs-007 + */ + public function testAReadThatFailsLeavesTheRowInTheRecordAndNamesWhy(): void { + $objects = $this->createMock(ObjectService::class); + $objects->method('find')->willThrowException(new \RuntimeException('db down')); + $objects->expects($this->never())->method('deleteObject'); + + $why = $this->storeOver(objects: $objects)->deleteRecorded(register: 'pipelinq', schema: 'ticket', id: 'ticket-1'); + + $this->assertNotSame('', $why); + $this->assertStringContainsString('db down', $why); + } + + /** + * A row that is not there reads as absent without an error; one that cannot be read comes back with its error. + * + * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-second-install-writes-nothing-req-exs-005 + */ + public function testReadTellsAbsentFromFailed(): void { + $objects = $this->createMock(ObjectService::class); + $objects->method('find')->willReturnCallback(static function (mixed $id): ?ObjectEntity { + return match ($id) { + 'absent' => throw new DoesNotExistException('not found'), + 'null' => null, + default => throw new \RuntimeException('db down'), + }; + }); + $store = $this->storeOver(objects: $objects); + + $this->assertSame(['row' => null, 'error' => ''], $store->read(register: 'r', schema: 's', id: 'absent')); + $this->assertSame(['row' => null, 'error' => ''], $store->read(register: 'r', schema: 's', id: 'null')); + $this->assertSame(['row' => null, 'error' => 'db down'], $store->read(register: 'r', schema: 's', id: 'broken')); + $this->assertNull($store->get(register: 'r', schema: 's', id: 'broken')); + } } diff --git a/tests/Unit/Service/ExampleSet/ExampleSetTestCase.php b/tests/Unit/Service/ExampleSet/ExampleSetTestCase.php index e21ac3482a..ad8e4396a2 100644 --- a/tests/Unit/Service/ExampleSet/ExampleSetTestCase.php +++ b/tests/Unit/Service/ExampleSet/ExampleSetTestCase.php @@ -68,6 +68,9 @@ abstract class ExampleSetTestCase extends TestCase { /** @var array The app config, by key. */ protected array $config = []; + /** @var array Which keys of the app config were stored as lazy values. */ + protected array $lazyKeys = []; + protected FakeExampleSetStore $store; protected FakeExampleSetAccounts $accounts; @@ -143,13 +146,19 @@ protected function setUp(): void { $instance = new ExampleSetInstance(container: $container, config: $system); $appConfig = $this->createMock(IAppConfig::class); - $appConfig->method('getValueString')->willReturnCallback(fn (string $app, string $key, string $default = ''): string => ($this->config[$key] ?? $default)); - $appConfig->method('setValueString')->willReturnCallback(function (string $app, string $key, string $value): bool { - $this->config[$key] = $value; + // A value is found only by the lazy flag it was stored with, as a lazy and a non-lazy lookup are separate stores. + $appConfig->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = '', bool $lazy = false): string => ( + isset($this->config[$key]) === true && ($this->lazyKeys[$key] ?? false) === $lazy ? $this->config[$key] : $default + ) + ); + $appConfig->method('setValueString')->willReturnCallback(function (string $app, string $key, string $value, bool $lazy = false): bool { + $this->config[$key] = $value; + $this->lazyKeys[$key] = $lazy; return true; }); $appConfig->method('deleteKey')->willReturnCallback(function (string $app, string $key): void { - unset($this->config[$key]); + unset($this->config[$key], $this->lazyKeys[$key]); }); $this->record = new ExampleSetRecord(appConfig: $appConfig); diff --git a/tests/Unit/Service/ExampleSet/Fake/FakeExampleSetStore.php b/tests/Unit/Service/ExampleSet/Fake/FakeExampleSetStore.php index 99bcba8c06..2937790fb5 100644 --- a/tests/Unit/Service/ExampleSet/Fake/FakeExampleSetStore.php +++ b/tests/Unit/Service/ExampleSet/Fake/FakeExampleSetStore.php @@ -178,10 +178,25 @@ public function find(string $register, string $schema, array $filters): array { return array_values($this->rows[$register.' '.$schema] ?? []); } + /** @var array Ids whose read fails with an error, as a database that is down would. */ + public array $unreadable = []; + public function get(string $register, string $schema, string $id): ?array { + if (in_array($id, $this->unreadable, true) === true) { + return null; + } + return ($this->rows[$register.' '.$schema][$id] ?? null); } + public function read(string $register, string $schema, string $id): array { + if (in_array($id, $this->unreadable, true) === true) { + return ['row' => null, 'error' => 'database is gone']; + } + + return ['row' => ($this->rows[$register.' '.$schema][$id] ?? null), 'error' => '']; + } + public function create(string $register, string $schema, array $data): array { if ($this->offers(register: $register, schema: $schema) === false) { return ['id' => '', 'error' => 'no such schema']; diff --git a/tests/Unit/Service/Export/ExportProfileServiceTest.php b/tests/Unit/Service/Export/ExportProfileServiceTest.php index 744dd4cc2b..a834f78230 100644 --- a/tests/Unit/Service/Export/ExportProfileServiceTest.php +++ b/tests/Unit/Service/Export/ExportProfileServiceTest.php @@ -235,7 +235,7 @@ public function testARetentionOutsideOneToNinetyDaysIsRefused(mixed $days): void * @return array */ public static function badRetentions(): array { - return ['zero' => [0], 'beyond the cap' => [91], 'a string' => ['30']]; + return ['zero' => [0], 'negative' => [-5], 'beyond the cap' => [91], 'a string' => ['30'], 'not a number' => ['abc'], 'a fraction' => [1.5]]; }//end badRetentions() public function testSomebodyElsesProfileIsRefused(): void { 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. * diff --git a/tests/Unit/Service/File/FileBatchHandlerTest.php b/tests/Unit/Service/File/FileBatchHandlerTest.php index ba767a7462..c934027d69 100644 --- a/tests/Unit/Service/File/FileBatchHandlerTest.php +++ b/tests/Unit/Service/File/FileBatchHandlerTest.php @@ -128,4 +128,21 @@ public function testBatchEmptyFileIds(): void { $this->handler->executeBatch($object, 'publish', []); } + + /** + * A delete the file service reports as not done is a failed item of the batch. + */ + public function testBatchDeleteOfAFileThatIsStillThereFails(): void { + $object = $this->createObjectEntity(); + + $this->fileService + ->method('deleteFile') + ->willReturnCallback(static fn ($file, $obj): bool => $file !== 43); + + $result = $this->handler->executeBatch($object, 'delete', [42, 43]); + + $this->assertEquals(1, $result['summary']['succeeded']); + $this->assertEquals(1, $result['summary']['failed']); + $this->assertFalse($result['results'][1]['success']); + } } diff --git a/tests/Unit/Service/FileServiceMoveFileTest.php b/tests/Unit/Service/FileServiceMoveFileTest.php new file mode 100644 index 0000000000..7dcfb81a93 --- /dev/null +++ b/tests/Unit/Service/FileServiceMoveFileTest.php @@ -0,0 +1,104 @@ + + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service + * + * @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/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service; + +use Exception; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\File\FileLockHandler; +use OCA\OpenRegister\Service\FileService; +use OCP\Files\File; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionProperty; + +/** + * @covers \OCA\OpenRegister\Service\FileService::moveFile + */ +class FileServiceMoveFileTest extends TestCase { + + /** + * A file service whose copy and delete are doubles, so only the move rule runs. + * + * @return FileService&MockObject The service. + */ + private function service(): FileService&MockObject { + $service = $this->getMockBuilder(FileService::class) + ->disableOriginalConstructor() + ->onlyMethods(['copyFile', 'deleteFile']) + ->getMock(); + + foreach (['fileLockHandler' => $this->createMock(FileLockHandler::class), 'logger' => $this->createMock(LoggerInterface::class)] as $name => $value) { + $property = new ReflectionProperty(FileService::class, $name); + $property->setValue($service, $value); + } + + return $service; + }//end service() + + /** + * A source that could not be deleted is not moved: the copy is taken back and the caller told. + * + * @return void + */ + public function testASourceThatSurvivesItsDeleteIsNotMoved(): void { + $copy = $this->createMock(File::class); + $source = new ObjectEntity(); + $target = new ObjectEntity(); + + $service = $this->service(); + $service->method('copyFile')->willReturn($copy); + $deleted = []; + $service->method('deleteFile')->willReturnCallback( + function ($file) use (&$deleted, $copy): bool { + $deleted[] = $file; + + return $file === $copy; + } + ); + + try { + $service->moveFile(sourceObject: $source, fileId: 7, targetObject: $target); + $this->fail('Expected the move to fail'); + } catch (Exception $e) { + $this->assertStringContainsString('could not be removed from the source object', $e->getMessage()); + } + + $this->assertSame([7, $copy], $deleted); + }//end testASourceThatSurvivesItsDeleteIsNotMoved() + + /** + * Control: a source that is deleted makes the copy the moved file. + * + * @return void + */ + public function testADeletedSourceIsAMove(): void { + $copy = $this->createMock(File::class); + + $service = $this->service(); + $service->method('copyFile')->willReturn($copy); + $service->expects($this->once())->method('deleteFile')->with(7)->willReturn(true); + + $this->assertSame($copy, $service->moveFile(sourceObject: new ObjectEntity(), fileId: 7, targetObject: new ObjectEntity())); + }//end testADeletedSourceIsAMove() +}//end class diff --git a/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php b/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php index 5d4ee4144a..2ad81c0cac 100644 --- a/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php +++ b/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php @@ -17,6 +17,7 @@ use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\FlowSuspension; use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys; use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent; use OCA\OpenRegister\Tests\Unit\Service\Flow\PublishedVersionDouble; use OCP\EventDispatcher\Event; @@ -77,6 +78,13 @@ public function execute(array $items, array $config, array $context): array { } } +/** A counting node that declares the config keys it reads, so a caller may only set those. */ +class KeyedCountingNode extends CountingNode implements IFlowNodeConfigKeys { + public function configKeys(): array { + return ['templateId', 'folder']; + } +} + /** Always throws, to exercise the FAILED path. */ class ExplodingNode implements IFlowNode { public function getId(): string { @@ -147,7 +155,7 @@ class FlowRunServiceExecuteNodeTest extends TestCase { private FlowRunMapper $mapper; private FlowRunService $service; - private CountingNode $target; + private KeyedCountingNode $target; private CountingNode $downstream; protected function setUp(): void { @@ -157,7 +165,7 @@ protected function setUp(): void { $this->mapper->method('insert')->willReturnArgument(0); $this->mapper->method('update')->willReturnArgument(0); - $this->target = new CountingNode('test.target'); + $this->target = new KeyedCountingNode('test.target'); $this->downstream = new CountingNode('test.downstream'); $exploding = new ExplodingNode(); $suspending = new SuspendingNode(); @@ -338,4 +346,43 @@ public function testTheCallersConfigOverlaysThePublishedStep(): void { $this->assertSame(['templateId' => 'picked', 'folder' => 'Besluiten'], $this->target->lastConfig); }//end testTheCallersConfigOverlaysThePublishedStep() + + /** + * A caller key the node does not declare never reaches it: the author's + * published value stays, and the extra key is dropped. + * + * @return void + */ + public function testACallerKeyOutsideTheNodesVocabularyDoesNotReachIt(): void { + $run = $this->service->queue(flowId: 'f1', trigger: FlowRunService::TRIGGER_DIRECT_NODE, user: 'alice'); + + $this->service->executeNode( + run: $run, + flow: $this->flowDoc(), + subject: new ExecuteNodeSubject(), + nodeId: 'target', + config: ['templateId' => 'picked', 'webhookUrl' => 'https://example.invalid/', 'folder' => 'Elders'] + ); + + $this->assertSame(['templateId' => 'picked', 'folder' => 'Elders'], $this->target->lastConfig); + }//end testACallerKeyOutsideTheNodesVocabularyDoesNotReachIt() + + /** + * A node that declares no config vocabulary takes nothing from the caller. + * + * @return void + */ + public function testANodeThatDeclaresNoKeysTakesNothingFromTheCaller(): void { + $run = $this->service->queue(flowId: 'f1', trigger: FlowRunService::TRIGGER_DIRECT_NODE, user: 'alice'); + + $this->service->executeNode( + run: $run, + flow: $this->flowDoc(), + subject: new ExecuteNodeSubject(), + nodeId: 'downstream', + config: ['anything' => 'x'] + ); + + $this->assertSame([], $this->downstream->lastConfig); + }//end testANodeThatDeclaresNoKeysTakesNothingFromTheCaller() }//end class 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 diff --git a/tests/Unit/Service/Form/Fakes/MemoryFile.php b/tests/Unit/Service/Form/Fakes/MemoryFile.php index 0d8d41abaa..03ee457068 100644 --- a/tests/Unit/Service/Form/Fakes/MemoryFile.php +++ b/tests/Unit/Service/Form/Fakes/MemoryFile.php @@ -18,6 +18,13 @@ */ class MemoryFile implements ISimpleFile { + /** + * The last change, unix seconds; a test sets it. + * + * @var int + */ + public int $mtime = 0; + public function __construct( private readonly string $name, private readonly MemoryFolder $folder, @@ -38,7 +45,7 @@ public function getETag(): string { }//end getETag() public function getMTime(): int { - return 0; + return $this->mtime; }//end getMTime() public function getContent(): string { 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/FormDependentsCheckTest.php b/tests/Unit/Service/Form/FormDependentsCheckTest.php index ae80ae64c0..c6e8819907 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. */ @@ -225,4 +256,16 @@ public function testAChangeThatBreaksNoFormAffectsNothing(): void { $this->assertSame([], $this->check->assess(schema: $schema)['affected']); }//end testAChangeThatBreaksNoFormAffectsNothing() + + /** + * A caller that judges a schema's forms itself claims the save, so the listener for every other writer leaves it. + */ + public function testASchemaSaveCanBeClaimedByItsCallerForTheRequest(): void { + $this->assertFalse($this->check->isClaimed(schemaId: 12)); + + $this->check->claim(schemaId: 12); + + $this->assertTrue($this->check->isClaimed(schemaId: 12)); + $this->assertFalse($this->check->isClaimed(schemaId: 13), 'a claim names one schema'); + }//end testASchemaSaveCanBeClaimedByItsCallerForTheRequest() }//end class diff --git a/tests/Unit/Service/Form/FormDestinationValidatorTest.php b/tests/Unit/Service/Form/FormDestinationValidatorTest.php index 17a6926681..d25a6f5531 100644 --- a/tests/Unit/Service/Form/FormDestinationValidatorTest.php +++ b/tests/Unit/Service/Form/FormDestinationValidatorTest.php @@ -135,6 +135,25 @@ public function testAServerSetDefaultedOrComputedPropertyIsNotReported(): void { $this->assertSame([], $findings); }//end testAServerSetDefaultedOrComputedPropertyIsNotReported() + /** + * A required generated identifier (`x-openregister-generated`) is filled by the server, so it is not reported either. + * + * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-drafts-owner-must-be-able-to-make-it-definitive-through-the-object-api + */ + public function testAGeneratedIdentifierIsNotReported(): void { + $schema = $this->caseSchema( + extraProperties: ['caseNumber' => ['type' => 'string', 'x-openregister-generated' => ['sequence' => 'case', 'format' => 'Z-{year}-{seq:4}']]], + required: ['title', 'caseNumber'] + ); + + $findings = $this->validator->validate( + mapping: ['fields' => [['field' => 'onderwerp', 'property' => 'title', 'type' => 'text', 'maxLength' => 100]]], + schema: $schema + ); + + $this->assertSame([], $findings); + }//end testAGeneratedIdentifierIsNotReported() + /** * A property-level `required: true` counts as required too. */ 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 863e05eb30..773f3d2684 100644 --- a/tests/Unit/Service/Form/FormSubmitServiceTest.php +++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php @@ -29,9 +29,11 @@ use OCA\OpenRegister\Service\Form\FormSubmitService; use OCA\OpenRegister\Service\Form\FormUploadStore; use OCA\OpenRegister\Service\Object\DraftOwner; +use OCA\OpenRegister\Service\Object\DraftStatusPolicy; 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; @@ -67,6 +69,7 @@ * @uses \OCA\OpenRegister\Service\Object\DraftFinder * @uses \OCA\OpenRegister\Service\Object\DraftOwner * @uses \OCA\OpenRegister\Service\Object\DraftStatusPolicy + * @uses \OCA\OpenRegister\Exception\DraftAlreadyLeftException */ class FormSubmitServiceTest extends TestCase { @@ -137,7 +140,27 @@ 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; + + /** + * Whether the fake save skips the real status policy, as a save that never reached it would. + * + * @var bool + */ + private bool $saveSkipsStatusPolicy = false; + protected function setUp(): void { + DraftStatusPolicy::resetPromotions(); $this->schemaBySlug = [ 'case' => $this->schema( id: 1, @@ -231,6 +254,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 +263,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 +354,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()]; @@ -344,6 +374,16 @@ private function fakeSave( $data = (array)$object; $status = ($data['@self']['status'] ?? null); unset($data['@self']); + + // The real status policy judges a save of a stored object, as SaveObject + // does: a promotion out of draft is allowed once, and an allowance for an + // object that is no longer a draft is refused. + $storedBefore = ($this->stored[(string)$uuid] ?? null); + if ($storedBefore !== null && $status !== null && $this->saveSkipsStatusPolicy === false) { + $storedBefore->setId(1); + (new DraftStatusPolicy())->resolveStatus(entity: $storedBefore, selfData: ['status' => $status]); + } + if ($status === 'draft') { // The fake listener stamps receipt fields only on a received object. $entity = new ObjectEntity(); @@ -457,6 +497,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. */ @@ -589,6 +696,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. */ @@ -901,4 +1038,161 @@ public function testAUsersDraftReadsBackThroughTheSubject(): void { $this->assertSame('Van Alice', $this->service->readDraft(destination: $destination, draftId: $draft['id'], subject: $alice)['data']['title']); $this->assertSame($draft['id'], $this->service->findDraft(destination: $destination, subject: $alice)['id'] ?? null); }//end testAUsersDraftReadsBackThroughTheSubject() + + /** + * A draft save never takes its uuid from the payload, so it cannot write over another owner's draft. + * + * The save takes a payload `id` as the uuid of an existing object and + * updates it, and a scope-value owner saves with RBAC off. Goes red with + * the id filter removed: the second save then carries `id` and the stored + * draft of subject 1 is the one the fake updates. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value + */ + public function testAPayloadIdNeverPicksTheUuidOfADraftSave(): void { + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $victim = $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'Van subject 1'], subject: null, owner: DraftOwner::portalSubject(subjectRef: 'subject-1')); + + $attacker = $this->service->saveDraft( + destination: $destination, + mapping: null, + payload: ['title' => 'Overschreven', 'id' => $victim['id'], 'uuid' => $victim['id']], + subject: null, + owner: DraftOwner::portalSubject(subjectRef: 'subject-2') + ); + + $this->assertNotSame($victim['id'], $attacker['id'], 'The second save made its own object.'); + $this->assertNull($this->saves[1]['uuid']); + $this->assertArrayNotHasKey('id', $this->saves[1]['object']); + $this->assertArrayNotHasKey('uuid', $this->saves[1]['object']); + $this->assertSame('Van subject 1', $this->stored[$victim['id']]->getObject()['title'], 'The other draft is unchanged.'); + $this->assertSame('portal:subject-1', $this->stored[$victim['id']]->getOwner()); + }//end testAPayloadIdNeverPicksTheUuidOfADraftSave() + + /** + * A fresh submit never takes its uuid from the payload, with or without a mapping. + * + * A mapping that points a field at the property `id`, and a fixed + * `@self.id`, are held to the same rule: the submit never names an + * existing object, the draft id is the only way to. + * + * @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 testAPayloadIdNeverPicksTheUuidOfASubmit(): void { + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $this->service->submit(destination: $destination, mapping: null, payload: ['title' => 'a', 'caseType' => 'b', 'id' => 'uuid-existing'], subject: null); + $this->service->submit( + destination: $destination, + mapping: [ + 'fields' => [['field' => 'mail', 'property' => 'id'], ['field' => 'onderwerp', 'property' => 'title']], + 'fixed' => ['caseType' => 'b', '@self' => ['id' => 'uuid-existing', 'owner' => 'x']], + ], + payload: ['mail' => 'uuid-existing', 'onderwerp' => 'a'], + subject: null + ); + + foreach ($this->saves as $save) { + $this->assertNull($save['uuid']); + $this->assertArrayNotHasKey('id', $save['object']); + $this->assertArrayNotHasKey('id', (array)($save['object']['@self'] ?? [])); + } + + $this->assertCount(2, $this->saves); + $this->assertNotSame('uuid-existing', $this->saves[0]['uuid']); + }//end testAPayloadIdNeverPicksTheUuidOfASubmit() + + /** + * Two requests on one draft: the one that finds it already sent is refused and announces nothing. + * + * The first request's save leaves draft between the second one's "is it + * still a draft" read and its save. The status policy refuses the second + * write (an allowance for an object that is no longer a draft), so no + * second `ObjectCreatedEvent` goes out. + * + * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft + */ + public function testALostRaceToLeaveDraftAnnouncesNothing(): void { + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $alice = $this->user(uid: 'alice'); + $draft = $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'Kapvergunning'], subject: $alice); + $this->duringSave = function () use ($draft): void { + $this->stored[$draft['id']]->setStatus('active'); + }; + + try { + $this->service->submit(destination: $destination, mapping: null, payload: ['caseType' => 'ct-1'], subject: $alice, draftId: $draft['id']); + $this->fail('The second send of one draft was accepted.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(404, $refused->getStatus()); + } + + $this->assertSame([], $this->dispatched, 'Nothing is announced a second time.'); + }//end testALostRaceToLeaveDraftAnnouncesNothing() + + /** + * A save that did not use the promotion allowance does not announce the creation either. + * + * The save is doubled as one that never reached the status policy and + * returned an object that was already `active`; goes red without the + * check on the allowance after the write. + * + * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft + */ + public function testASaveThatDidNotUseThePromotionAnnouncesNothing(): void { + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $alice = $this->user(uid: 'alice'); + $draft = $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'Kapvergunning'], subject: $alice); + $this->saveSkipsStatusPolicy = true; + + try { + $this->service->submit(destination: $destination, mapping: null, payload: ['caseType' => 'ct-1'], subject: $alice, draftId: $draft['id']); + $this->fail('A save that did not leave draft was announced.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame(404, $refused->getStatus()); + } + + $this->assertSame([], $this->dispatched); + }//end testASaveThatDidNotUseThePromotionAnnouncesNothing() + + /** + * A draft save claims its upload tokens: the file sits on the draft, so the token cannot be redeemed again. + * + * A refused draft save keeps the token for the retry, as a refused submit does. Goes red without the claim. + * + * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft + */ + public function testADraftSaveClaimsItsUploadTokens(): void { + $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: $this->schemaBySlug['case']->getProperties()['bijlage'], + file: ['name' => 'a.pdf', 'type' => 'application/pdf', 'tmp_name' => $path, 'error' => UPLOAD_ERR_OK, 'size' => 8] + )['token']; + $destination = ['register' => 'dossiq', 'schema' => 'case']; + $payload = ['title' => 'Half af', 'bijlage' => ['uploadToken' => $token]]; + $alice = $this->user(uid: 'alice'); + + // Refused first: the token survives for the retry. + $this->refuseSave['case'] = new CustomValidationException(message: 'x', errors: ['title' => 'nope']); + try { + $this->service->saveDraft(destination: $destination, mapping: null, payload: $payload, subject: $alice, scope: 'form-1'); + $this->fail('Refused draft save accepted.'); + } catch (FormSubmitRefusedException) { + $this->assertSame('a.pdf', $this->uploads->materialise(formId: 'form-1', token: $token)['name']); + } + + unset($this->refuseSave['case']); + $this->service->saveDraft(destination: $destination, mapping: null, payload: $payload, subject: $alice, scope: 'form-1'); + $this->assertSame('a.pdf', $this->saves[1]['files']['bijlage']['name']); + + try { + $this->uploads->materialise(formId: 'form-1', token: $token); + $this->fail('The token of a saved draft is still redeemable.'); + } catch (FormSubmitRefusedException $refused) { + $this->assertSame('upload-token-unknown', $refused->getFindings()[0]['code'] ?? null); + } + }//end testADraftSaveClaimsItsUploadTokens() }//end class diff --git a/tests/Unit/Service/Form/FormUploadStoreTest.php b/tests/Unit/Service/Form/FormUploadStoreTest.php index 9dbbb64107..410124a16d 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. */ @@ -193,4 +208,24 @@ public function testThePurgeCountsExpiredTokens(): void { $this->assertSame('b', file_get_contents($this->store->materialise(formId: 'f', token: $kept)['tmp_name'])); $this->assertSame(0, $this->store->purge()); }//end testThePurgeCountsExpiredTokens() + + /** + * The purge deletes the bytes of an upload whose record was never written, once a token's lifetime has passed. + * + * issue() writes the bytes first, so a failed second write leaves a `.bin` no record points + * at. A young one may still be an upload in flight and stays. Goes red without the `.bin` pass. + */ + public function testThePurgeDeletesAnOrphanedBytesFile(): void { + $this->store->issue(formId: 'f', property: 'bijlage', rule: ['type' => 'file'], file: $this->upload('a')); + $folder = $this->appData->folders[FormUploadStore::FOLDER]; + $folder->newFile('0123456789abcdef0123456789abcdef.bin', 'orphan'); + $folder->newFile('fedcba9876543210fedcba9876543210.bin', 'in flight'); + $folder->getFile('0123456789abcdef0123456789abcdef.bin')->mtime = ($this->now - FormUploadStore::TTL); + $folder->getFile('fedcba9876543210fedcba9876543210.bin')->mtime = ($this->now - 60); + + $this->assertSame(1, $this->store->purge()); + $this->assertFalse($folder->fileExists('0123456789abcdef0123456789abcdef.bin'), 'The orphan is gone.'); + $this->assertTrue($folder->fileExists('fedcba9876543210fedcba9876543210.bin'), 'A young bytes file may still be in flight.'); + $this->assertCount(3, $folder->files, 'The live token (bytes and record) and the young file stay.'); + }//end testThePurgeDeletesAnOrphanedBytesFile() }//end class diff --git a/tests/Unit/Service/ImportServiceMigrationPackTest.php b/tests/Unit/Service/ImportServiceMigrationPackTest.php index aad0fcfb1e..6e9a3d382a 100644 --- a/tests/Unit/Service/ImportServiceMigrationPackTest.php +++ b/tests/Unit/Service/ImportServiceMigrationPackTest.php @@ -30,6 +30,7 @@ namespace OCA\OpenRegister\Tests\Unit\Service; +use OCA\OpenRegister\Tests\Unit\Service\MigrationPack\PassThroughL10nTrait; use OCA\OpenRegister\Db\Register; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; @@ -45,6 +46,8 @@ use ReflectionClass; class ImportServiceMigrationPackTest extends TestCase { + use PassThroughL10nTrait; + /** @var SchemaMapper&MockObject */ private SchemaMapper $schemaMapper; @@ -76,7 +79,7 @@ protected function setUp(): void { $groupManager, $translationCsvCodec, $this->createMock(\OCA\OpenRegister\Db\AuditTrailMapper::class), - new MappingEngine(), + new MappingEngine($this->passThroughL10n()), $this->validateObjectHandler, $this->createMock(\Psr\Container\ContainerInterface::class) ); diff --git a/tests/Unit/Service/ImportServiceRegisterAutoCreateTest.php b/tests/Unit/Service/ImportServiceRegisterAutoCreateTest.php index 95a31f4780..13575a6059 100644 --- a/tests/Unit/Service/ImportServiceRegisterAutoCreateTest.php +++ b/tests/Unit/Service/ImportServiceRegisterAutoCreateTest.php @@ -30,6 +30,7 @@ namespace OCA\OpenRegister\Tests\Unit\Service; +use OCA\OpenRegister\Tests\Unit\Service\MigrationPack\PassThroughL10nTrait; use GuzzleHttp\Client; use OCA\OpenRegister\Db\ConfigurationMapper; use OCA\OpenRegister\Db\MagicMapper; @@ -57,6 +58,8 @@ use ReflectionClass; class ImportServiceRegisterAutoCreateTest extends TestCase { + use PassThroughL10nTrait; + /** @var RegisterMapper&MockObject */ private RegisterMapper $registerMapper; @@ -125,7 +128,7 @@ static function (string $id) use ($importHandler) { $this->createMock(IGroupManager::class), $translationCsvCodec, $this->createMock(\OCA\OpenRegister\Db\AuditTrailMapper::class), - new MappingEngine(), + new MappingEngine($this->passThroughL10n()), $this->createMock(ValidateObject::class), $container ); diff --git a/tests/Unit/Service/ImportServiceTest.php b/tests/Unit/Service/ImportServiceTest.php index f0771e4f5d..16dca27b76 100644 --- a/tests/Unit/Service/ImportServiceTest.php +++ b/tests/Unit/Service/ImportServiceTest.php @@ -20,6 +20,7 @@ use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\ImportService; use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Tests\Unit\Service\MigrationPack\PassThroughL10nTrait; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; @@ -36,6 +37,8 @@ * Tests import logic, data transformation, and caching. */ class ImportServiceTest extends TestCase { + use PassThroughL10nTrait; + /** @var MagicMapper&MockObject */ private MagicMapper $objectMapper; @@ -75,7 +78,7 @@ protected function setUp(): void { $this->groupManager, $translationCsvCodec, $this->createMock(\OCA\OpenRegister\Db\AuditTrailMapper::class), - new \OCA\OpenRegister\Service\MigrationPack\MappingEngine(), + new \OCA\OpenRegister\Service\MigrationPack\MappingEngine($this->passThroughL10n()), $this->createMock(\OCA\OpenRegister\Service\Object\ValidateObject::class), $this->createMock(\Psr\Container\ContainerInterface::class) ); diff --git a/tests/Unit/Service/Integration/CallSignatureTest.php b/tests/Unit/Service/Integration/CallSignatureTest.php new file mode 100644 index 0000000000..788699d396 --- /dev/null +++ b/tests/Unit/Service/Integration/CallSignatureTest.php @@ -0,0 +1,60 @@ + + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Integration + * + * @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/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Integration; + +use OCA\OpenRegister\Service\Integration\CallSignature; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Service\Integration\CallSignature + */ +class CallSignatureTest extends TestCase { + + public function testAParameterOfThatNameIsAccepted(): void { + $service = new class () { + public function call($source, bool $withholdBodies = false): void { + } + }; + + $this->assertTrue((new CallSignature())->accepts(service: $service, method: 'call', name: 'withholdBodies')); + }//end testAParameterOfThatNameIsAccepted() + + public function testAMethodThatCollectsNamedArgumentsAcceptsAnyName(): void { + $service = new class () { + public function call(...$arguments): void { + } + }; + + $this->assertTrue((new CallSignature())->accepts(service: $service, method: 'call', name: 'withholdBodies')); + }//end testAMethodThatCollectsNamedArgumentsAcceptsAnyName() + + public function testAnOlderSignatureAndAMissingMethodAreNotAccepted(): void { + $older = new class () { + public function call($source, string $endpoint = ''): void { + } + }; + + $this->assertFalse((new CallSignature())->accepts(service: $older, method: 'call', name: 'withholdBodies')); + $this->assertFalse((new CallSignature())->accepts(service: new \stdClass(), method: 'call', name: 'withholdBodies')); + }//end testAnOlderSignatureAndAMissingMethodAreNotAccepted() +}//end class diff --git a/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php b/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php index a5719f648b..2302cc4653 100644 --- a/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php +++ b/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php @@ -41,6 +41,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** @@ -362,10 +363,11 @@ function ( * * @param ObjectService $objectService Answers the source lookup. * @param object|null $callService The connector CallService stand-in. + * @param LoggerInterface|null $logger The logger to assert on; a null logger when none. * * @return ExternalIntegrationRouter */ - private function routerOn(ObjectService $objectService, ?object $callService = null): ExternalIntegrationRouter { + private function routerOn(ObjectService $objectService, ?object $callService = null, ?LoggerInterface $logger = null): ExternalIntegrationRouter { $appManager = $this->createMock(IAppManager::class); $appManager->method('isInstalled')->willReturn(true); $appManager->method('isEnabledForUser')->willReturn(true); @@ -385,7 +387,7 @@ static function (string $id) use ($objectService, $callService) { } ); - return new ExternalIntegrationRouter($appManager, $container, new NullLogger()); + return new ExternalIntegrationRouter($appManager, $container, ($logger ?? new NullLogger())); }//end routerOn() /** @@ -1014,13 +1016,15 @@ public function testAConnectorThatCannotWithholdBodiesIsNotCalled(): void { $callService = new FakeCallService( $this->integriqCallLog(['statusCode' => 200, 'response' => ['statusCode' => 200, 'body' => '{}', 'encoding' => 'UTF-8']]) ); - $router = $this->routerOn($this->objectService(['integriq/brp-haalcentraal' => $source]), $callService); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('error'); + $router = $this->routerOn($this->objectService(['integriq/brp-haalcentraal' => $source]), $callService, $logger); try { $router->callWithMeta(new FakeExternalProvider(source: 'brp-haalcentraal'), 'POST', 'personen', ['withholdBodies' => true]); $this->fail('Expected ProviderUnavailableException'); } catch (ProviderUnavailableException $e) { - $this->assertSame(ProviderUnavailableException::CAUSE_UPSTREAM_SERVICE_DOWN, $e->getCause()); + $this->assertSame(ProviderUnavailableException::CAUSE_CONNECTOR_CANNOT_WITHHOLD_BODIES, $e->getCause()); } $this->assertNull($callService->receivedSource); diff --git a/tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php b/tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php index 575765d2a0..27c9a799a3 100644 --- a/tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php +++ b/tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php @@ -207,4 +207,16 @@ public function testAnUnexpectedFailureIsLoggedWithoutTheAddress(): void { $this->assertCount(1, $this->logged); $this->assertStringNotContainsString(self::ADDRESS, implode("\n", $this->logged)); }//end testAnUnexpectedFailureIsLoggedWithoutTheAddress() + + public function testAConnectorThatCannotWithholdBodiesAnswersItsOwnCause(): void { + $this->router->method('sourceConfiguration')->willReturn(['residentCount' => true]); + $this->router->method('callWithMeta')->willThrowException( + new ProviderUnavailableException(message: 'old connector', cause: ProviderUnavailableException::CAUSE_CONNECTOR_CANNOT_WITHHOLD_BODIES) + ); + + $this->assertSame( + ['unavailable' => true, 'cause' => 'connector-cannot-withhold-bodies'], + $this->provider->countResidentsAtAddressObject(self::ADDRESS) + ); + }//end testAConnectorThatCannotWithholdBodiesAnswersItsOwnCause() }//end class diff --git a/tests/Unit/Service/MigrationPack/MappingEngineTest.php b/tests/Unit/Service/MigrationPack/MappingEngineTest.php index dede9f9ef9..784f84efd7 100644 --- a/tests/Unit/Service/MigrationPack/MappingEngineTest.php +++ b/tests/Unit/Service/MigrationPack/MappingEngineTest.php @@ -31,11 +31,13 @@ use PHPUnit\Framework\TestCase; class MappingEngineTest extends TestCase { + use PassThroughL10nTrait; + private MappingEngine $engine; protected function setUp(): void { parent::setUp(); - $this->engine = new MappingEngine(); + $this->engine = new MappingEngine($this->passThroughL10n()); } private function pack(array $fieldMappings, array $extra = []): array { @@ -303,4 +305,36 @@ public function testIsRowSkippedRespectsSkipRows(): void { public function testIsRowSkippedDefaultsToFalseWhenNoSkipRows(): void { $this->assertFalse($this->engine->isRowSkipped([], 1)); } + + /** + * The row errors the preview and the import show go through the app's translations. + * + * @return void + */ + public function testEveryRowErrorIsTranslated(): void { + $engine = new MappingEngine($this->passThroughL10n(prefix: 'NL:')); + $result = $engine->mapRow( + $this->pack( + [ + ['source' => 'Req', 'target' => 'a', 'required' => true], + ['source' => 'Odd', 'target' => 'b', 'transform' => ['type' => 'weird']], + ['source' => 'When', 'target' => 'c', 'transform' => ['type' => 'date', 'sourceFormat' => 'Y-m-d']], + ['source' => 'Looked', 'target' => 'd', 'transform' => ['type' => 'lookup', 'map' => ['x' => 1]]], + ] + ), + ['Odd' => 'v', 'When' => 'not a date', 'Looked' => 'y'], + 1 + ); + + $messages = array_column($result['errors'], 'message'); + $this->assertCount(4, $messages); + foreach ($messages as $message) { + $this->assertStringStartsWith('NL:', $message); + } + + $this->assertContains('NL:Required source field "Req" is missing or empty', $messages); + $this->assertContains('NL:Unknown transform type "weird"', $messages); + $this->assertContains('NL:Could not parse date "not a date" with format "Y-m-d"', $messages); + $this->assertContains('NL:Value "y" has no mapping and no default is configured', $messages); + }//end testEveryRowErrorIsTranslated() } diff --git a/tests/Unit/Service/MigrationPack/PackDefinitionValidatorTest.php b/tests/Unit/Service/MigrationPack/PackDefinitionValidatorTest.php index 2458c46fdb..f1a01e70e9 100644 --- a/tests/Unit/Service/MigrationPack/PackDefinitionValidatorTest.php +++ b/tests/Unit/Service/MigrationPack/PackDefinitionValidatorTest.php @@ -30,11 +30,13 @@ use PHPUnit\Framework\TestCase; class PackDefinitionValidatorTest extends TestCase { + use PassThroughL10nTrait; + private PackDefinitionValidator $validator; protected function setUp(): void { parent::setUp(); - $this->validator = new PackDefinitionValidator(); + $this->validator = new PackDefinitionValidator($this->passThroughL10n()); } /** @@ -282,4 +284,43 @@ public function testAssertValidDoesNotThrowForAValidPack(): void { $this->validator->assertValid($this->validPack()); $this->addToAssertionCount(1); } + + /** + * Every finding goes through the app's translations: the pack editor lists them under the save error, and a + * Dutch administrator would otherwise read English findings on a Dutch page. + * + * @return void + */ + public function testEveryFindingIsTranslated(): void { + $validator = new PackDefinitionValidator($this->passThroughL10n(prefix: 'NL:')); + $errors = $validator->validate( + [ + 'id' => 'Not A Slug', + 'name' => '', + 'sourceFormat' => 'xml', + 'version' => '1', + 'fieldMappings' => [ + 'not an object', + ['source' => '', 'target' => '', 'required' => 'yes', 'transform' => 'nope'], + ['source' => 'a', 'target' => 'b', 'transform' => ['type' => 'weird']], + ['source' => 'a', 'target' => 'b', 'transform' => ['type' => 'date', 'sourceFormat' => 5, 'targetFormat' => 5]], + ['source' => 'a', 'target' => 'b', 'transform' => ['type' => 'lookup']], + ['source' => 'a', 'target' => 'b', 'transform' => ['type' => 'concat', 'fields' => ['']]], + ['source' => 'a', 'target' => 'b', 'transform' => ['type' => 'const']], + ], + 'defaults' => 'x', + 'skipRows' => [0], + 'idStrategy' => ['type' => 'sourceField'], + ] + ); + + $this->assertGreaterThan(15, count($errors)); + foreach ($errors as $error) { + $this->assertStringStartsWith('NL:', $error); + } + + // The row label is part of the translated message, with the 1-based number the editor shows. + $this->assertContains('NL:Mapping row 1 must be an object', $errors); + $this->assertContains('NL:Mapping row 2: source is required and must be a non-empty string', $errors); + }//end testEveryFindingIsTranslated() } diff --git a/tests/Unit/Service/MigrationPack/PassThroughL10nTrait.php b/tests/Unit/Service/MigrationPack/PassThroughL10nTrait.php new file mode 100644 index 0000000000..6876110863 --- /dev/null +++ b/tests/Unit/Service/MigrationPack/PassThroughL10nTrait.php @@ -0,0 +1,39 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/migration-mapping-packs/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\MigrationPack; + +use OCP\IL10N; + +/** + * Hands a test an IL10N whose `t()` is `vsprintf`, optionally prefixed. + */ +trait PassThroughL10nTrait { + + /** + * An IL10N that renders the English text with its parameters. + * + * @param string $prefix Put before every translated message (`NL:` to prove a message went through `t()`). + * + * @return IL10N + */ + private function passThroughL10n(string $prefix = ''): IL10N { + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback( + static fn (string $text, array|string $parameters = []): string => $prefix . vsprintf($text, (array)$parameters) + ); + + return $l10n; + }//end passThroughL10n() +}//end trait diff --git a/tests/Unit/Service/MigrationPackServiceTest.php b/tests/Unit/Service/MigrationPackServiceTest.php index 52cffe0898..b5c320b743 100644 --- a/tests/Unit/Service/MigrationPackServiceTest.php +++ b/tests/Unit/Service/MigrationPackServiceTest.php @@ -29,6 +29,7 @@ namespace OCA\OpenRegister\Tests\Unit\Service; +use OCA\OpenRegister\Tests\Unit\Service\MigrationPack\PassThroughL10nTrait; use DateTime; use DateTimeImmutable; use InvalidArgumentException; @@ -48,6 +49,8 @@ use RuntimeException; class MigrationPackServiceTest extends TestCase { + use PassThroughL10nTrait; + private MigrationPackService $service; private MigrationPackMapper&MockObject $mapper; private IAppConfig&MockObject $appConfig; @@ -74,8 +77,8 @@ function (string $app, string $key, array $value): bool { ); $this->service = new MigrationPackService( $this->mapper, - new PackDefinitionValidator(), - new MappingEngine(), + new PackDefinitionValidator($this->passThroughL10n()), + new MappingEngine($this->passThroughL10n()), $this->appConfig ); } @@ -227,7 +230,7 @@ public function testDeletingABuiltinPackKeepsTheRowWhenItsSlugCannotBeRemembered $appConfig = $this->createMock(IAppConfig::class); $appConfig->method('getValueArray')->willReturn([]); $appConfig->method('setValueArray')->willThrowException(new RuntimeException('app config unavailable')); - $service = new MigrationPackService($this->mapper, new PackDefinitionValidator(), new MappingEngine(), $appConfig); + $service = new MigrationPackService($this->mapper, new PackDefinitionValidator($this->passThroughL10n()), new MappingEngine($this->passThroughL10n()), $appConfig); $this->mapper->method('find')->willReturn($this->builtinPack()); $this->mapper->expects($this->never())->method('delete'); diff --git a/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php b/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php index c41deed1e6..95ea806a92 100644 --- a/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php +++ b/tests/Unit/Service/Notification/ReferencedAddressNotifierTest.php @@ -21,9 +21,12 @@ namespace OCA\OpenRegister\Tests\Unit\Service\Notification; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\Notification\EmailSender; use OCA\OpenRegister\Service\Notification\OptOutAuthority; use OCA\OpenRegister\Service\Notification\ReferencedAddressNotifier; +use OCA\OpenRegister\Service\Object\SaveObject\RelationCascadeHandler; use OCA\OpenRegister\Service\ObjectService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -55,6 +58,25 @@ class ReferencedAddressNotifierTest extends TestCase { */ private $optOut; + /** + * Resolves a `$ref` to a schema id. + * + * @var RelationCascadeHandler&MockObject + */ + private $refs; + + /** + * The schema mapper, for the referenced objects' own properties. + * + * @var SchemaMapper&MockObject + */ + private $schemas; + + /** + * The schema every `$ref` resolves to in these tests: id 7, slug `supplier`. + */ + private const SUPPLIER_SCHEMA = '7'; + /** * What reached the mailer. * @@ -80,6 +102,10 @@ protected function setUp(): void { $this->objects->method('find')->willReturnCallback( fn (int|string $id): ?ObjectEntity => ($this->store[(string)$id] ?? null) ); + $this->schemas = $this->createMock(SchemaMapper::class); + $this->schemas->method('find')->willReturnCallback( + fn (int|string $id): Schema => $this->schema(['next' => ['type' => 'string', '$ref' => '#/components/schemas/supplier']]) + ); $this->email = $this->createMock(EmailSender::class); $this->email->method('sendToAddress')->willReturnCallback( function (string $address, string $displayName, string $subject, string $body, ?array $unsubscribe = null): string { @@ -87,6 +113,10 @@ function (string $address, string $displayName, string $subject, string $body, ? return EmailSender::OUTCOME_DISPATCHED; } ); + $this->refs = $this->createMock(RelationCascadeHandler::class); + $this->refs->method('resolveSchemaReference')->willReturnCallback( + static fn (string $reference): ?string => ($reference === '#/components/schemas/supplier' ? self::SUPPLIER_SCHEMA : null) + ); $this->optOut = $this->createMock(OptOutAuthority::class); $this->optOut->method('withLink')->willReturnCallback( static fn (string $body, ?array $unsubscribe): string => $body . (($unsubscribe['url'] ?? '') !== '' ? "\n\nstop: " . $unsubscribe['url'] : '') @@ -106,22 +136,55 @@ private function notifier(): ReferencedAddressNotifier { objects: $this->objects, email: $this->email, optOut: $this->optOut, - logger: $this->createMock(LoggerInterface::class) + logger: $this->createMock(LoggerInterface::class), + schemas: $this->schemas, + refs: $this->refs ); }//end notifier() + /** + * A schema with the given properties. + * + * @param array $properties Its properties. + * + * @return Schema + */ + private function schema(array $properties): Schema { + $schema = new Schema(); + $schema->setProperties($properties); + return $schema; + }//end schema() + + /** + * The triggering object's schema: every property named here is a reference to a supplier. + * + * @param array $properties The reference properties. + * + * @return Schema + */ + private function referencing(array $properties): Schema { + $declared = []; + foreach ($properties as $property) { + $declared[$property] = ['type' => 'string', '$ref' => '#/components/schemas/supplier']; + } + + return $this->schema($declared); + }//end referencing() + /** * A stored object. * * @param string $uuid Its uuid. * @param array $data Its data. * @param string|null $organisation Its organisation. + * @param string $schema Its schema id. * * @return ObjectEntity */ - private function object(string $uuid, array $data, ?string $organisation = 'org-1'): ObjectEntity { + private function object(string $uuid, array $data, ?string $organisation = 'org-1', string $schema = self::SUPPLIER_SCHEMA): ObjectEntity { $object = new ObjectEntity(); $object->setUuid($uuid); + $object->setSchema($schema); $object->setObject($data); $object->setOrganisation($organisation); return $object; @@ -162,7 +225,8 @@ public function testTheAddressIsReadThroughTheReferenceAndMailed(): void { recipientsSpec: [['kind' => 'email', 'field' => 'supplierRef.contactEmail']], subject: 'Nieuw bericht', body: 'U heeft een nieuw bericht.', - category: 'service' + category: 'service', + schema: $this->referencing(['supplierRef']) ); $this->assertSame([['field' => 'supplierRef.contactEmail', 'outcome' => EmailSender::OUTCOME_DISPATCHED]], $outcomes); @@ -185,7 +249,10 @@ public function testAPlainFieldAndAnObjectShapedReferenceBothResolve(): void { $notifier = $this->notifier(); $this->assertSame('a@example.org', $notifier->resolveAddress(object: $object, data: $object->getObject(), path: 'email')['address']); - $this->assertSame('b@example.org', $notifier->resolveAddress(object: $object, data: $object->getObject(), path: 'supplier.contactEmail')['address']); + $this->assertSame( + 'b@example.org', + $notifier->resolveAddress(object: $object, data: $object->getObject(), path: 'supplier.contactEmail', schema: $this->referencing(['supplier']))['address'] + ); }//end testAPlainFieldAndAnObjectShapedReferenceBothResolve() /** @@ -199,6 +266,7 @@ public function testAnUnresolvableAddressIsNamedAndNothingIsSent(): void { $this->store['sup-bad'] = $this->object('sup-bad', ['contactEmail' => 'not an address']); $object = $this->object('m', ['empty' => '', 'missingRef' => 'gone', 'foreign' => 'sup-other', 'bad' => 'sup-bad']); $notifier = $this->notifier(); + $schema = $this->referencing(['empty', 'missingRef', 'foreign', 'bad', 'ref', 'glob']); $cases = [ 'empty.contactEmail' => ReferencedAddressNotifier::REASON_NO_REFERENCE, @@ -208,7 +276,7 @@ public function testAnUnresolvableAddressIsNamedAndNothingIsSent(): void { 'nothing' => ReferencedAddressNotifier::REASON_NO_REFERENCE, ]; foreach ($cases as $path => $reason) { - $resolved = $notifier->resolveAddress(object: $object, data: $object->getObject(), path: $path); + $resolved = $notifier->resolveAddress(object: $object, data: $object->getObject(), path: $path, schema: $schema); $this->assertNull($resolved['address'], $path); $this->assertSame($reason, $resolved['reason'], $path); } @@ -217,8 +285,8 @@ public function testAnUnresolvableAddressIsNamedAndNothingIsSent(): void { // any object may read a referenced object that has no organisation. $this->store['global'] = $this->object('global', ['contactEmail' => 'g@example.org'], null); $untenanted = $this->object('u', ['ref' => 'sup-other', 'glob' => 'global'], null); - $this->assertSame(ReferencedAddressNotifier::REASON_OTHER_TENANT, $notifier->resolveAddress(object: $untenanted, data: $untenanted->getObject(), path: 'ref.contactEmail')['reason']); - $this->assertSame('g@example.org', $notifier->resolveAddress(object: $object, data: ['glob' => 'global'], path: 'glob.contactEmail')['address']); + $this->assertSame(ReferencedAddressNotifier::REASON_OTHER_TENANT, $notifier->resolveAddress(object: $untenanted, data: $untenanted->getObject(), path: 'ref.contactEmail', schema: $schema)['reason']); + $this->assertSame('g@example.org', $notifier->resolveAddress(object: $object, data: ['glob' => 'global'], path: 'glob.contactEmail', schema: $schema)['address']); $outcomes = $notifier->notify( object: $object, @@ -226,7 +294,8 @@ public function testAnUnresolvableAddressIsNamedAndNothingIsSent(): void { recipientsSpec: [['kind' => 'email', 'field' => 'foreign.contactEmail']], subject: 's', body: 'b', - category: 'service' + category: 'service', + schema: $schema ); $this->assertSame([['field' => 'foreign.contactEmail', 'outcome' => ReferencedAddressNotifier::REASON_OTHER_TENANT]], $outcomes); $this->assertSame([], $this->sent); @@ -258,7 +327,8 @@ public function testIntegriqRefusalsAreRespected(): void { ], subject: 's', body: 'b', - category: 'service' + category: 'service', + schema: $this->referencing(['refA', 'refB']) ); $this->assertSame( @@ -284,4 +354,82 @@ public function testARuleWithoutEmailEntriesDoesNothing(): void { $this->notifier()->notify(object: $object, data: [], recipientsSpec: [['kind' => 'users', 'users' => ['a']]], subject: 's', body: 'b', category: 'service') ); }//end testARuleWithoutEmailEntriesDoesNothing() + + /** + * A reference to an object of another schema than the property points at + * is refused as not found, and nothing is sent: the reference value is + * data the person saving the triggering object controls, so it must not + * reach any same-organisation object that happens to carry the field. + * + * @return void + */ + public function testAReferenceToAnObjectOfAnotherSchemaIsRefusedAndNothingIsSent(): void { + $this->optOut->method('ask')->willReturnCallback( + function (string $channel, string $category, array $addresses): array { + $this->assertSame([], $addresses, 'no address of the wrong object may be put to integriq'); + return []; + } + ); + $this->store['staff-1'] = $this->object('staff-1', ['contactEmail' => 'staff@example.org'], 'org-1', '99'); + $message = $this->object('msg-1', ['supplierRef' => 'staff-1']); + + $outcomes = $this->notifier()->notify( + object: $message, + data: $message->getObject(), + recipientsSpec: [['kind' => 'email', 'field' => 'supplierRef.contactEmail']], + subject: 's', + body: 'b', + category: 'service', + schema: $this->referencing(['supplierRef']) + ); + + $this->assertSame([['field' => 'supplierRef.contactEmail', 'outcome' => ReferencedAddressNotifier::REASON_REFERENCE_NOT_FOUND]], $outcomes); + $this->assertSame([], $this->sent); + }//end testAReferenceToAnObjectOfAnotherSchemaIsRefusedAndNothingIsSent() + + /** + * A reference property that declares no `$ref` (or no schema is known) + * names nothing to check against, so it is not followed. + * + * @return void + */ + public function testAReferencePropertyWithoutARefIsNotFollowed(): void { + $this->store['sup-1'] = $this->object('sup-1', ['contactEmail' => 'info@bouw.example']); + $message = $this->object('msg-1', ['supplierRef' => 'sup-1']); + $notifier = $this->notifier(); + + $plain = $this->schema(['supplierRef' => ['type' => 'string', 'format' => 'uuid']]); + $this->assertSame( + ReferencedAddressNotifier::REASON_REFERENCE_NOT_FOUND, + $notifier->resolveAddress(object: $message, data: $message->getObject(), path: 'supplierRef.contactEmail', schema: $plain)['reason'] + ); + $this->assertSame( + ReferencedAddressNotifier::REASON_REFERENCE_NOT_FOUND, + $notifier->resolveAddress(object: $message, data: $message->getObject(), path: 'supplierRef.contactEmail')['reason'] + ); + $this->assertNull($notifier->resolveAddress(object: $message, data: $message->getObject(), path: 'supplierRef.contactEmail', schema: $plain)['address']); + }//end testAReferencePropertyWithoutARefIsNotFollowed() + + /** + * Every hop of a longer path is held to the schema its own property + * declares, read from the schema of the object reached. + * + * @return void + */ + public function testEveryHopOfALongerPathIsCheckedAgainstItsOwnSchema(): void { + $this->store['sup-1'] = $this->object('sup-1', ['next' => 'sup-2']); + $this->store['sup-2'] = $this->object('sup-2', ['contactEmail' => 'deep@example.org']); + $this->store['other'] = $this->object('other', ['contactEmail' => 'staff@example.org'], 'org-1', '99'); + $notifier = $this->notifier(); + $schema = $this->referencing(['first']); + + $ok = $this->object('m-1', ['first' => 'sup-1']); + $this->assertSame('deep@example.org', $notifier->resolveAddress(object: $ok, data: $ok->getObject(), path: 'first.next.contactEmail', schema: $schema)['address']); + + $this->store['sup-1'] = $this->object('sup-1', ['next' => 'other']); + $this->assertSame( + ReferencedAddressNotifier::REASON_REFERENCE_NOT_FOUND, + $notifier->resolveAddress(object: $ok, data: $ok->getObject(), path: 'first.next.contactEmail', schema: $schema)['reason'] + ); + }//end testEveryHopOfALongerPathIsCheckedAgainstItsOwnSchema() }//end class diff --git a/tests/Unit/Service/Object/DraftActivationTest.php b/tests/Unit/Service/Object/DraftActivationTest.php index aa1c47eda7..460c6bb524 100644 --- a/tests/Unit/Service/Object/DraftActivationTest.php +++ b/tests/Unit/Service/Object/DraftActivationTest.php @@ -52,6 +52,7 @@ * @uses \OCA\OpenRegister\Exception\DraftActivationRefusedException * @uses \OCA\OpenRegister\Exception\NotAuthorizedException * @uses \OCA\OpenRegister\Exception\ValidationException + * @uses \OCA\OpenRegister\Exception\DraftAlreadyLeftException * @uses \OCA\OpenRegister\Event\ObjectActivatedEvent * @uses \OCA\OpenRegister\Event\ObjectCreatedEvent * @uses \OCA\OpenRegister\Service\Object\ObjectEventGate @@ -96,6 +97,20 @@ class DraftActivationTest extends TestCase { */ private ?\Throwable $refuseSave = null; + /** + * Run inside the next save before the status policy judges it, as a parallel request would. + * + * @var callable|null + */ + private $beforeGate = null; + + /** + * Whether the save skips the status policy, as one that never reached it would. + * + * @var bool + */ + private bool $saveSkipsGate = false; + private DraftActivation $activation; private Register $register; @@ -232,8 +247,17 @@ private function realGateSave( unset($data['@self']); $entity = $this->stored[(string)$uuid]; + if ($this->beforeGate !== null) { + $hook = $this->beforeGate; + $this->beforeGate = null; + $hook(); + } + // The real gate: throws unless exactly this object was allowed one promotion. - $entity->setStatus((new DraftStatusPolicy())->resolveStatus(entity: $entity, selfData: $self)); + if ($this->saveSkipsGate === false) { + $entity->setStatus((new DraftStatusPolicy())->resolveStatus(entity: $entity, selfData: $self)); + } + $entity->setObject($data + ['number' => 'T-2026-0001']); return $entity; @@ -574,4 +598,70 @@ public function testTheDraftIsReadInItsOwnRegisterAndSchema(): void { $this->assertSame(['id' => 'd1', 'rbac' => false, 'register' => $this->register, 'schema' => $this->schema], $this->finds[0]); }//end testTheDraftIsReadInItsOwnRegisterAndSchema() + + /** + * Two activations of one draft: the one that finds it already sent announces nothing. + * + * A parallel request makes the draft definitive between this one's read and + * its save. The real policy refuses the save (an allowance for an object + * that is no longer a draft): 409, nothing written, no second creation event. + * + * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft + */ + public function testALostRaceToActivateAnnouncesNothing(): void { + $this->store(uuid: 'd1', owner: 'agent', status: 'draft', data: ['subject' => 'A', 'channel' => 'phone']); + $this->beforeGate = function (): void { + $this->stored['d1']->setStatus('active'); + }; + + $refused = $this->refusalOf(draftId: 'd1', owner: DraftOwner::ofUser(user: $this->user(uid: 'agent'))); + + $this->assertSame(409, $refused->getStatus()); + $this->assertSame(DraftActivationRefusedException::NOT_A_DRAFT, $refused->getReason()); + $this->assertSame([], $this->dispatched); + }//end testALostRaceToActivateAnnouncesNothing() + + /** + * A save that did not use the promotion allowance does not announce the creation either. + * + * The save returns an object that was already `active` without the policy + * consuming the allowance; goes red without the check after the write. + * + * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft + */ + public function testASaveThatDidNotUseThePromotionAnnouncesNothing(): void { + $this->store(uuid: 'd1', owner: 'agent', status: 'draft', data: ['subject' => 'A', 'channel' => 'phone']); + $this->saveSkipsGate = true; + $this->beforeGate = function (): void { + $this->stored['d1']->setStatus('active'); + }; + + $refused = $this->refusalOf(draftId: 'd1', owner: DraftOwner::ofUser(user: $this->user(uid: 'agent'))); + + $this->assertSame(DraftActivationRefusedException::NOT_A_DRAFT, $refused->getReason()); + $this->assertSame([], $this->dispatched); + }//end testASaveThatDidNotUseThePromotionAnnouncesNothing() + + /** + * A required property that carries a generated identifier does not keep a draft from leaving draft. + * + * The listener issues the number on the save that leaves draft, so the + * draft cannot hold it; excused like `serverSet`, or such a draft could + * never be sent. Goes red without the `x-openregister-generated` excusal. + * + * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-drafts-owner-must-be-able-to-make-it-definitive-through-the-object-api + */ + public function testAGeneratedRequiredIdentifierDoesNotBlockActivation(): void { + $this->schema->setRequired(['subject', 'channel', 'caseNumber']); + $this->schema->setProperties( + $this->schema->getProperties() + [ + 'caseNumber' => ['type' => 'string', 'x-openregister-generated' => ['sequence' => 'case', 'format' => 'Z-{year}-{seq:4}']], + ] + ); + $this->store(uuid: 'd1', owner: 'agent', status: 'draft', data: ['subject' => 'A', 'channel' => 'phone']); + + $saved = $this->activation->activate(register: $this->register, schema: $this->schema, draftId: 'd1', owner: DraftOwner::ofUser(user: $this->user(uid: 'agent'))); + + $this->assertSame('active', $saved->getStatus()); + }//end testAGeneratedRequiredIdentifierDoesNotBlockActivation() }//end class diff --git a/tests/Unit/Service/Object/DraftExpiryTest.php b/tests/Unit/Service/Object/DraftExpiryTest.php index d9b2dd45c2..a9980de4dd 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; @@ -57,6 +58,13 @@ class DraftExpiryTest extends TestCase { */ private array $searches = []; + /** + * Counts asked for what lies beyond the read cap: the query. + * + * @var array> + */ + private array $counts = []; + /** * Deletes run: uuid, scope and flags. * @@ -85,6 +93,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. * @@ -108,7 +130,32 @@ function (array $query = [], bool $_rbac = true, bool $_multitenancy = true): ar throw new RuntimeException('table missing'); } - return array_slice(($this->stored[$key] ?? []), (int)$query['_offset'], (int)$query['_limit']); + return array_slice($this->matching(query: $query), (int)$query['_offset'], (int)$query['_limit']); + } + ); + $objects->method('countSearchObjects')->willReturnCallback( + function (array $query = [], bool $_rbac = true, bool $_multitenancy = true): int { + $this->counts[] = $query; + + return count($this->matching(query: $query)); + } + ); + $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( @@ -165,6 +212,45 @@ function (string $message): void { $this->expiry = new DraftExpiry(objects: $objects, registers: $registerMapper, schemas: $schemaMapper, logger: $logger); }//end setUp() + /** + * What the store answers for a list query: the stored objects the `@self` date filters let through, in list order. + * + * Only the two filters the sweep sends are understood: `updated` before a moment, and + * `updated` null with `created` before a moment. Whatever else is stored (a non-object) + * comes back as it is, for the sweep's own check to turn down. + * + * @param array $query The list query. + * + * @return array The matching objects. + */ + private function matching(array $query): array { + $self = $query['@self']; + $list = ($this->stored[$self['register'] . '/' . $self['schema']] ?? []); + + return array_values( + array_filter( + $list, + static function (mixed $entity) use ($self): bool { + if (($entity instanceof ObjectEntity) === false) { + return true; + } + + $before = static fn (?\DateTime $moment, string $limit): bool => $moment !== null + && $moment->getTimestamp() < (new DateTimeImmutable($limit, new \DateTimeZone('UTC')))->getTimestamp(); + if (is_array($self['updated'] ?? null) === true) { + return $before($entity->getUpdated(), $self['updated']['lt']); + } + + if (($self['updated'] ?? null) === 'IS NULL') { + return $entity->getUpdated() === null && $before($entity->getCreated(), $self['created']['lt']); + } + + return true; + } + ) + ); + }//end matching() + /** * A schema, with or without a draft lifetime. * @@ -260,7 +346,12 @@ public function testAnExpiredDraftIsRemovedAndAYoungOneStays(): void { $this->assertFalse($this->deletes[0]['retentionSweep']); // The list asked for drafts alone, of everybody, in that register and schema. - $this->assertSame(['register' => '10', 'schema' => '1'], $this->searches[0]['query']['@self']); + $this->assertSame('10', $this->searches[0]['query']['@self']['register']); + $this->assertSame('1', $this->searches[0]['query']['@self']['schema']); + // ... and only the expired ones: last changed before the cutoff (seven days before the sweep, in UTC), then the never-changed ones. + $this->assertSame(['lt' => '2026-10-05 03:00:00'], $this->searches[0]['query']['@self']['updated']); + $this->assertSame('IS NULL', $this->searches[1]['query']['@self']['updated']); + $this->assertSame(['lt' => '2026-10-05 03:00:00'], $this->searches[1]['query']['@self']['created']); $this->assertSame('true', $this->searches[0]['query']['_drafts']); $this->assertFalse($this->searches[0]['rbac']); $this->assertFalse($this->searches[0]['multitenancy']); @@ -442,6 +533,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. */ @@ -455,10 +574,12 @@ public function testEveryPageIsReadBeforeTheFirstDelete(): void { $counts = $this->expiry->sweep(now: $this->now); $this->assertSame(DraftExpiry::PAGE_SIZE + 5, $counts['removed']); - $this->assertCount(2, $this->searches); + // Two pages of expired drafts, then the one read for drafts that were never changed. + $this->assertCount(3, $this->searches); $this->assertSame(0, $this->searches[0]['query']['_offset']); $this->assertSame(DraftExpiry::PAGE_SIZE, $this->searches[1]['query']['_offset']); $this->assertSame(DraftExpiry::PAGE_SIZE, $this->searches[1]['query']['_limit']); + $this->assertSame([], $this->counts, 'Nothing lies beyond the cap, so nothing is counted.'); }//end testEveryPageIsReadBeforeTheFirstDelete() /** @@ -481,4 +602,65 @@ public function testAnUnreadableSchemaOrListIsSkipped(): void { $this->warnings ); }//end testAnUnreadableSchemaOrListIsSkipped() + + /** + * Young drafts never hide an expired one: the store is asked for expired drafts alone, so the read cap holds expired rows only. + * + * Goes red without the date filter in the query: the first MAX_PAGES x PAGE_SIZE + * rows are then the young drafts and the expired ones behind them are never read. + */ + public function testYoungDraftsDoNotFillTheReadWindow(): void { + $this->schema(id: 1, slug: 'ticket', configuration: ['draftLifetimeDays' => 7]); + $this->register(id: 10, slug: 'service', schemaIds: [1]); + for ($i = 0; $i < (DraftExpiry::MAX_PAGES * DraftExpiry::PAGE_SIZE); $i++) { + $this->object(where: '10/1', uuid: 'young-' . $i, status: 'draft', updated: '2026-10-11T00:00:00+00:00'); + } + + $this->object(where: '10/1', uuid: 'old-1', status: 'draft', updated: '2026-09-01T00:00:00+00:00'); + $this->object(where: '10/1', uuid: 'old-2', status: 'draft', updated: '2026-09-02T00:00:00+00:00'); + + $counts = $this->expiry->sweep(now: $this->now); + + $this->assertSame(['old-1', 'old-2'], array_column($this->deletes, 'uuid')); + $this->assertSame(['schemas' => 1, 'expired' => 2, 'removed' => 2, 'refused' => 0, 'deferred' => 0], $this->totals(counts: $counts)); + }//end testYoungDraftsDoNotFillTheReadWindow() + + /** + * Expired drafts beyond the read cap are counted as deferred, not missing from the counts (decision 228). + * + * The cap is MAX_PAGES full pages per register and schema. A preview (nothing removed) shows + * the whole picture: what was read, and what lies beyond it. + */ + public function testExpiredDraftsBeyondTheReadCapAreCountedAsDeferred(): void { + $this->schema(id: 1, slug: 'ticket', configuration: ['draftLifetimeDays' => 7]); + $this->register(id: 10, slug: 'service', schemaIds: [1]); + $cap = (DraftExpiry::MAX_PAGES * DraftExpiry::PAGE_SIZE); + for ($i = 0; $i < ($cap + 20); $i++) { + $this->object(where: '10/1', uuid: 'old-' . $i, status: 'draft', updated: '2026-09-01T00:00:00+00:00'); + } + + $counts = $this->expiry->preview(now: $this->now); + + $this->assertSame(['schemas' => 1, 'expired' => ($cap + 20), 'removed' => 0, 'refused' => 0, 'deferred' => ($cap + 20)], $this->totals(counts: $counts)); + $this->assertCount(DraftExpiry::MAX_PAGES, $this->searches, 'The read stops at the cap; the second list is not read, its rows are counted.'); + $this->assertCount(2, $this->counts, 'The remainder of both lists is counted.'); + }//end testExpiredDraftsBeyondTheReadCapAreCountedAsDeferred() + + /** + * A sweep removes what it read and counts what is left beyond the cap as deferred. + */ + public function testASweepCountsWhatIsLeftBeyondTheCapAsDeferred(): void { + $this->schema(id: 1, slug: 'ticket', configuration: ['draftLifetimeDays' => 7]); + $this->register(id: 10, slug: 'service', schemaIds: [1]); + $cap = (DraftExpiry::MAX_PAGES * DraftExpiry::PAGE_SIZE); + for ($i = 0; $i < ($cap + 20); $i++) { + $this->object(where: '10/1', uuid: 'old-' . $i, status: 'draft', updated: '2026-09-01T00:00:00+00:00'); + } + + $counts = $this->expiry->sweep(now: $this->now); + + $this->assertSame(DraftExpiry::MAX_REMOVALS, $counts['removed']); + $this->assertSame($cap + 20, $counts['expired']); + $this->assertSame(($cap + 20) - DraftExpiry::MAX_REMOVALS, $counts['deferred'], 'Nothing is missing from the counts: expired = removed + refused + deferred.'); + }//end testASweepCountsWhatIsLeftBeyondTheCapAsDeferred() }//end class diff --git a/tests/Unit/Service/Object/DraftOwnerTest.php b/tests/Unit/Service/Object/DraftOwnerTest.php index 9602f7ec65..0835e08e64 100644 --- a/tests/Unit/Service/Object/DraftOwnerTest.php +++ b/tests/Unit/Service/Object/DraftOwnerTest.php @@ -61,6 +61,19 @@ public function testAUserOwnerIsTheUid(): void { $this->assertFalse($owner->isScoped()); }//end testAUserOwnerIsTheUid() + /** + * The owner is the asserted one, else the signed-in subject, else nobody. + */ + public function testTheOwnerIsTheAssertedOneElseTheSubjectElseNobody(): void { + $alice = $this->user(uid: 'alice'); + $portal = DraftOwner::portalSubject(subjectRef: 'subject-1'); + + $this->assertSame($portal, DraftOwner::resolve(subject: $alice, owner: $portal)); + $this->assertSame('alice', DraftOwner::resolve(subject: $alice, owner: null)?->value()); + $this->assertSame($portal, DraftOwner::resolve(subject: null, owner: $portal)); + $this->assertNull(DraftOwner::resolve(subject: null, owner: null)); + }//end testTheOwnerIsTheAssertedOneElseTheSubjectElseNobody() + /** * A portal subject owner is `portal:`, scoped, without a user. */ diff --git a/tests/Unit/Service/Object/DraftStatusPolicyTest.php b/tests/Unit/Service/Object/DraftStatusPolicyTest.php index 4dfa6dde70..498cbf04da 100644 --- a/tests/Unit/Service/Object/DraftStatusPolicyTest.php +++ b/tests/Unit/Service/Object/DraftStatusPolicyTest.php @@ -26,6 +26,7 @@ * @uses \OCA\OpenRegister\Db\ObjectEntity * @uses \OCA\OpenRegister\Db\Schema * @uses \OCA\OpenRegister\Exception\ValidationException + * @uses \OCA\OpenRegister\Exception\DraftAlreadyLeftException * @uses \OCA\OpenRegister\Service\Form\FormDestinationValidator */ class DraftStatusPolicyTest extends TestCase { @@ -133,15 +134,16 @@ public function testServerFilledPropertiesAreExcusedWhenAbsent(): void { 'total' => ['type' => 'number', 'computed' => ['expression' => 'a + b']], 'number' => ['type' => 'string', 'x-openregister' => ['serverSet' => true]], 'reference' => ['type' => 'string', 'x-openregister' => ['serverSet' => false]], + 'caseNumber' => ['type' => 'string', 'x-openregister-generated' => ['sequence' => 'case', 'format' => 'Z-{year}-{seq:4}']], ] ); $this->assertSame( - ['priority' => 'server-set', 'total' => 'server-set', 'number' => 'server-set'], + ['priority' => 'server-set', 'total' => 'server-set', 'number' => 'server-set', 'caseNumber' => 'server-set'], $this->policy->serverFilledExcusals(schema: $schema, object: ['title' => 'x']) ); // A value the caller did supply is judged like any other. - $this->assertSame(['total' => 'server-set'], $this->policy->serverFilledExcusals(schema: $schema, object: ['priority' => 'high', 'number' => 'T-1'])); + $this->assertSame(['total' => 'server-set', 'caseNumber' => 'server-set'], $this->policy->serverFilledExcusals(schema: $schema, object: ['priority' => 'high', 'number' => 'T-1'])); }//end testServerFilledPropertiesAreExcusedWhenAbsent() /** @@ -226,4 +228,41 @@ public function testTheHiddenDraftClause(): void { $this->assertSame("(t._status IS NULL OR t._status <> 'draft')", $this->policy->notADraftSql(columnPrefix: 't.')); $this->assertSame("(_status IS NULL OR _status <> 'draft')", $this->policy->notADraftSql(columnPrefix: '')); }//end testTheHiddenDraftClause() + + /** + * An allowance for an object that is no longer a draft is refused, so a parallel request that lost the race writes nothing. + * + * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft + */ + public function testAnAllowanceForAnObjectThatAlreadyLeftDraftIsRefused(): void { + DraftStatusPolicy::allowPromotion(uuid: 'uuid-1'); + + $this->expectException(\OCA\OpenRegister\Exception\DraftAlreadyLeftException::class); + $this->policy->resolveStatus(entity: $this->entity(status: 'active'), selfData: ['status' => 'active']); + }//end testAnAllowanceForAnObjectThatAlreadyLeftDraftIsRefused() + + /** + * Control: without an allowance, and for a new object, an unchanged status is no refusal. + * + * @return void + */ + public function testNoAllowanceNoRefusal(): void { + $this->assertSame('active', $this->policy->resolveStatus(entity: $this->entity(status: 'active'), selfData: ['status' => 'active'])); + + DraftStatusPolicy::allowPromotion(uuid: 'uuid-1'); + $this->assertNull($this->policy->resolveStatus(entity: $this->entity(status: null, stored: false), selfData: [])); + }//end testNoAllowanceNoRefusal() + + /** + * The allowance is used by the save that leaves draft, and only then. + * + * @return void + */ + public function testThePromotionIsUsedBySavingTheDraft(): void { + DraftStatusPolicy::allowPromotion(uuid: 'uuid-1'); + $this->assertFalse(DraftStatusPolicy::promotionWasUsed(uuid: 'uuid-1')); + + $this->assertSame('active', $this->policy->resolveStatus(entity: $this->entity(status: 'draft'), selfData: ['status' => 'active'])); + $this->assertTrue(DraftStatusPolicy::promotionWasUsed(uuid: 'uuid-1')); + }//end testThePromotionIsUsedBySavingTheDraft() }//end class diff --git a/tests/Unit/Service/Object/MergeHandlerFileTransferTest.php b/tests/Unit/Service/Object/MergeHandlerFileTransferTest.php new file mode 100644 index 0000000000..7a8d841fd0 --- /dev/null +++ b/tests/Unit/Service/Object/MergeHandlerFileTransferTest.php @@ -0,0 +1,144 @@ + + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Object + * + * @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/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Object; + +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\Object\MergeHandler; +use OCP\Files\File; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * @covers \OCA\OpenRegister\Service\Object\MergeHandler + */ +class MergeHandlerFileTransferTest extends TestCase { + + private FileService&MockObject $fileService; + + private MergeHandler $handler; + + protected function setUp(): void { + parent::setUp(); + + $this->fileService = $this->createMock(FileService::class); + $this->handler = new MergeHandler( + $this->createMock(MagicMapper::class), + $this->fileService, + $this->createMock(IUserSession::class) + ); + }//end setUp() + + /** + * A source file. + * + * @return File&MockObject + */ + private function sourceFile(): File&MockObject { + $file = $this->createMock(File::class); + $file->method('getName')->willReturn('besluit.pdf'); + $file->method('getContent')->willReturn('bytes'); + + return $file; + }//end sourceFile() + + /** + * Call a private method of the handler. + * + * @param string $method The method. + * + * @return array The result. + */ + private function invoke(string $method): array { + $reflection = new ReflectionMethod(MergeHandler::class, $method); + $arguments = [new ObjectEntity()]; + if ($method === 'transferObjectFiles') { + $arguments[] = new ObjectEntity(); + } + + return $reflection->invokeArgs($this->handler, $arguments); + }//end invoke() + + /** + * A source file that survives its delete is a failed transfer, and the copy is taken back. + * + * @return void + */ + public function testATransferThatCouldNotRemoveTheSourceIsReportedAsFailedAndTheCopyIsTakenBack(): void { + $copy = $this->createMock(File::class); + $this->fileService->method('getFiles')->willReturn([$this->sourceFile()]); + $this->fileService->method('addFile')->willReturn($copy); + + $deleted = []; + $this->fileService->method('deleteFile')->willReturnCallback( + function ($file) use (&$deleted, $copy): bool { + $deleted[] = $file; + + return $file === $copy; + } + ); + + $result = $this->invoke(method: 'transferObjectFiles'); + + $this->assertSame(0, $result['transferred']); + $this->assertFalse($result['files'][0]['success']); + $this->assertSame('transfer_failed', $result['files'][0]['action']); + $this->assertContains($copy, $deleted); + }//end testATransferThatCouldNotRemoveTheSourceIsReportedAsFailedAndTheCopyIsTakenBack() + + /** + * A delete the source refused is a failed delete, not a deleted file. + * + * @return void + */ + public function testADeleteOfAFileThatIsStillThereIsReportedAsFailed(): void { + $this->fileService->method('getFiles')->willReturn([$this->sourceFile()]); + $this->fileService->method('deleteFile')->willReturn(false); + + $result = $this->invoke(method: 'deleteObjectFiles'); + + $this->assertSame(0, $result['deleted']); + $this->assertSame('delete_failed', $result['files'][0]['action']); + }//end testADeleteOfAFileThatIsStillThereIsReportedAsFailed() + + /** + * Control: a file that is really deleted is a transfer and a delete. + * + * @return void + */ + public function testAFileThatWasRemovedIsReportedAsDone(): void { + $this->fileService->method('getFiles')->willReturn([$this->sourceFile()]); + $this->fileService->method('addFile')->willReturn($this->createMock(File::class)); + $this->fileService->method('deleteFile')->willReturn(true); + + $this->assertSame(1, $this->invoke(method: 'transferObjectFiles')['transferred']); + $this->assertSame(1, $this->invoke(method: 'deleteObjectFiles')['deleted']); + }//end testAFileThatWasRemovedIsReportedAsDone() +}//end class diff --git a/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php b/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php index a5cd550a0d..7ba3e61bb4 100644 --- a/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php +++ b/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php @@ -167,4 +167,75 @@ 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 { + $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'); + $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($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: $groupManager, + 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. + return \OCA\OpenRegister\Service\SystemOperationContext::run( + static fn (): array => $handler->filterUuidsForPermissions(uuids: ['draft-bob', 'draft-alice', 'record'], _rbac: true, _multitenancy: false) + ); + }//end bulkDeleteFilterAs() }//end class diff --git a/tests/Unit/Service/Object/PermissionHandlerDraftRuleTest.php b/tests/Unit/Service/Object/PermissionHandlerDraftRuleTest.php new file mode 100644 index 0000000000..c55dd620e2 --- /dev/null +++ b/tests/Unit/Service/Object/PermissionHandlerDraftRuleTest.php @@ -0,0 +1,275 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Object + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Object; + +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\ConditionMatcher; +use OCA\OpenRegister\Service\Object\PermissionHandler; +use OCA\OpenRegister\Service\SystemOperationContext; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Locks the draft rule of PermissionHandler::hasPermission(). + */ +class PermissionHandlerDraftRuleTest extends TestCase { + + /** + * Build a handler for a caller. + * + * @param string|null $uid The signed-in user, null for nobody. + * @param array $groups The caller's groups. + * @param bool $commandLine Whether the handler reads as the command line. + * + * @return PermissionHandler The handler under test. + */ + private function handlerFor(?string $uid, array $groups=['staff'], bool $commandLine=false): PermissionHandler { + $user = null; + if ($uid !== null) { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + } + + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + $userManager = $this->createMock(IUserManager::class); + $userManager->method('get')->willReturn($user); + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('getUserGroupIds')->willReturn($groups); + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willThrowException(new \RuntimeException('not available')); + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool')->willReturnCallback( + static fn (string $app, string $key, bool $default=false): bool => ($key === 'rbac.inherit_from_public_default' || $default) + ); + + return new class ( + $session, + $userManager, + $groupManager, + $this->createMock(SchemaMapper::class), + $this->createMock(MagicMapper::class), + $this->createMock(ConditionMatcher::class), + $appConfig, + $this->createMock(LoggerInterface::class), + $container, + $commandLine + ) extends PermissionHandler { + /** + * @param mixed $session The session. + * @param mixed $userManager The user manager. + * @param mixed $groupManager The group manager. + * @param mixed $schemaMapper The schema mapper. + * @param mixed $mapper The object mapper. + * @param mixed $matcher The condition matcher. + * @param mixed $appConfig The app config. + * @param mixed $logger The logger. + * @param mixed $container The container. + * @param bool $commandLine Whether to read as the command line. + */ + public function __construct( + $session, + $userManager, + $groupManager, + $schemaMapper, + $mapper, + $matcher, + $appConfig, + $logger, + $container, + private readonly bool $commandLine + ) { + parent::__construct($session, $userManager, $groupManager, $schemaMapper, $mapper, $matcher, $appConfig, $logger, $container); + } + + /** + * @return bool + */ + protected function isCommandLine(): bool { + return $this->commandLine; + } + }; + }//end handlerFor() + + /** + * A schema whose rules let anybody do anything, so only the draft rule can refuse. + * + * @param bool $draftsVisible Whether the schema shows its drafts to others. + * + * @return Schema The schema. + */ + private function schema(bool $draftsVisible=false): Schema { + $schema = new Schema(); + $schema->setId(5); + $schema->setAuthorization( + [ + 'read' => ['authenticated', 'public'], + 'update' => ['authenticated', 'public'], + 'delete' => ['authenticated', 'public'], + ] + ); + $schema->setConfiguration(['draftsVisible' => $draftsVisible]); + + return $schema; + }//end schema() + + /** + * A stored object. + * + * @param string $status The lifecycle status. + * @param string $owner The owner. + * + * @return ObjectEntity The object. + */ + private function object(string $status, string $owner='alice'): ObjectEntity { + $object = new ObjectEntity(); + $object->setUuid('11111111-1111-4111-8111-111111111111'); + $object->setStatus($status); + $object->setOwner($owner); + $object->setSchema('5'); + + return $object; + }//end object() + + /** + * Every action on somebody else's draft is refused for a non-owner, and passes for the same object once it is not a draft. + * + * Fails without the draft rule: the schema's rules grant every verb to a + * signed-in user. + * + * @return void + */ + public function testSomebodyElsesDraftIsRefusedForEveryAction(): void { + $handler = $this->handlerFor(uid: 'bob'); + foreach (['read', 'update', 'delete'] as $action) { + $this->assertFalse( + $handler->hasPermission(schema: $this->schema(), action: $action, objectOwner: 'alice', object: $this->object(status: 'draft')), + "bob may not $action alice's draft" + ); + $this->assertTrue( + $handler->hasPermission(schema: $this->schema(), action: $action, objectOwner: 'alice', object: $this->object(status: 'active')), + "bob may $action alice's definitive object" + ); + } + }//end testSomebodyElsesDraftIsRefusedForEveryAction() + + /** + * The explicit user id is the subject, not the session's user. + * + * @return void + */ + public function testTheExplicitUserIdIsTheSubject(): void { + $handler = $this->handlerFor(uid: 'alice'); + $this->assertFalse( + $handler->hasPermission(schema: $this->schema(), action: 'read', userId: 'bob', objectOwner: 'alice', object: $this->object(status: 'draft')) + ); + }//end testTheExplicitUserIdIsTheSubject() + + /** + * The owner keeps their own draft. + * + * @return void + */ + public function testTheOwnerKeepsTheirDraft(): void { + $handler = $this->handlerFor(uid: 'alice'); + $this->assertTrue($handler->hasPermission(schema: $this->schema(), action: 'read', objectOwner: 'alice', object: $this->object(status: 'draft'))); + $this->assertTrue($handler->hasPermission(schema: $this->schema(), action: 'delete', objectOwner: 'alice', object: $this->object(status: 'draft'))); + }//end testTheOwnerKeepsTheirDraft() + + /** + * An administrator reads every draft, as on the read path. + * + * @return void + */ + public function testAnAdministratorIsNotKeptFromADraft(): void { + $handler = $this->handlerFor(uid: 'root', groups: ['admin']); + $this->assertTrue($handler->hasPermission(schema: $this->schema(), action: 'read', objectOwner: 'alice', object: $this->object(status: 'draft'))); + }//end testAnAdministratorIsNotKeptFromADraft() + + /** + * A schema that sets draftsVisible lets others reach its drafts. + * + * @return void + */ + public function testASchemaWithVisibleDraftsLetsOthersIn(): void { + $handler = $this->handlerFor(uid: 'bob'); + $this->assertTrue($handler->hasPermission(schema: $this->schema(draftsVisible: true), action: 'read', objectOwner: 'alice', object: $this->object(status: 'draft'))); + }//end testASchemaWithVisibleDraftsLetsOthersIn() + + /** + * A check without an object is not a draft question: the rule asks only when it is handed the stored object. + * + * @return void + */ + public function testACheckWithoutAnObjectIsUnchanged(): void { + $handler = $this->handlerFor(uid: 'bob'); + $this->assertTrue($handler->hasPermission(schema: $this->schema(), action: 'read', objectOwner: 'alice')); + }//end testACheckWithoutAnObjectIsUnchanged() + + /** + * RBAC off and a system operation are not kept from a draft: their caller judges them. + * + * @return void + */ + public function testRbacOffAndTheSystemContextPass(): void { + $handler = $this->handlerFor(uid: 'bob'); + $draft = $this->object(status: 'draft'); + $this->assertTrue($handler->hasPermission(schema: $this->schema(), action: 'update', objectOwner: 'alice', _rbac: false, object: $draft)); + $this->assertTrue( + SystemOperationContext::run(fn (): bool => $handler->hasPermission(schema: $this->schema(), action: 'update', objectOwner: 'alice', object: $draft)) + ); + }//end testRbacOffAndTheSystemContextPass() + + /** + * An anonymous web caller is kept from a draft, and the command line without a user is the system. + * + * @return void + */ + public function testAnAnonymousWebCallerIsKeptAndTheCommandLineIsNot(): void { + $draft = $this->object(status: 'draft'); + $this->assertFalse( + $this->handlerFor(uid: null, commandLine: false)->hasPermission(schema: $this->schema(), action: 'read', objectOwner: 'alice', object: $draft) + ); + $this->assertTrue( + $this->handlerFor(uid: null, commandLine: true)->hasPermission(schema: $this->schema(), action: 'read', objectOwner: 'alice', object: $draft) + ); + }//end testAnAnonymousWebCallerIsKeptAndTheCommandLineIsNot() +}//end class diff --git a/tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php b/tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php index f18b3c3ce4..d54d01b685 100644 --- a/tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php +++ b/tests/Unit/Service/Object/ReferentialIntegrityKeyReferenceTest.php @@ -30,7 +30,11 @@ use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\Archival\ArchivalRetentionGuard; use OCA\OpenRegister\Service\Object\ReferentialIntegrityService; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use OCP\DB\IPreparedStatement; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\ICache; use OCP\ICacheFactory; use OCP\IDBConnection; @@ -74,6 +78,27 @@ class ReferentialIntegrityKeyReferenceTest extends TestCase { */ private ReferentialIntegrityService $service; + /** + * The mapper mock: it names the citing table and its columns. + * + * @var MagicMapper&MockObject + */ + private MagicMapper $mapper; + + /** + * The SQL the citing-table query was prepared with. + * + * @var string + */ + private string $sql = ''; + + /** + * The platform the connection reports; MySQL unless a test says otherwise. + * + * @var AbstractPlatform + */ + private AbstractPlatform $reported; + /** * Set up. * @@ -85,10 +110,19 @@ protected function setUp(): void { $cacheFactory = $this->createMock(ICacheFactory::class); $cacheFactory->method('createDistributed')->willReturn($this->createMock(ICache::class)); + // The citing table, as the mapper names it and as the connection prefixes it. + $this->mapper = $this->createMock(MagicMapper::class); + $this->mapper->method('getTableNameForRegisterSchema')->willReturn('openregister_table_7_2'); + $queryBuilder = $this->createMock(IQueryBuilder::class); + $queryBuilder->method('getTableName')->willReturnCallback(static fn (string $table): string => 'oc_' . $table); + $this->db->method('getQueryBuilder')->willReturn($queryBuilder); + $this->reported = new MySQLPlatform(); + $this->db->method('getDatabasePlatform')->willReturnCallback(fn (): AbstractPlatform => $this->reported); + $this->service = new ReferentialIntegrityService( schemaMapper: $this->schemaMapper, registerMapper: $this->createMock(RegisterMapper::class), - objectEntityMapper: $this->createMock(MagicMapper::class), + objectEntityMapper: $this->mapper, auditTrailMapper: $this->createMock(AuditTrailMapper::class), logger: $this->createMock(LoggerInterface::class), db: $this->db, @@ -102,10 +136,11 @@ protected function setUp(): void { * * @param array $labelsProperty The citing property's definition. * @param bool $withRegister Whether the citing schema lives in a register. + * @param array|null $columns The citing table's column map; null gives `labels` its column. * * @return void */ - private function schemas(array $labelsProperty, bool $withRegister = true): void { + private function schemas(array $labelsProperty, bool $withRegister = true, ?array $columns = null): void { $label = new Schema(); $label->setId(1); $label->setSlug('label'); @@ -120,6 +155,8 @@ private function schemas(array $labelsProperty, bool $withRegister = true): void $note->setRequired([]); $this->schemaMapper->method('findAll')->willReturn([$label, $note]); + // The citing table's column for its property, as the table was built (a collision suffix included). + $this->mapper->method('buildTableColumnsFromSchema')->willReturn($columns ?? ['labels' => ['name' => 'labels_1', 'type' => 'json']]); $register = new Register(); $register->setId(7); @@ -174,7 +211,13 @@ function (?array $bound = null) use (&$params) { } ); $statement->method('fetchAll')->willReturn($rows); - $this->db->method('prepare')->willReturn($statement); + $this->db->method('prepare')->willReturnCallback( + function (string $sql) use ($statement) { + $this->sql = $sql; + + return $statement; + } + ); }//end citingRows() /** @@ -272,4 +315,57 @@ public function testKeyReferenceOnlyRestricts(): void { self::assertFalse($this->service->hasIncomingOnDeleteReferences('1')); }//end testKeyReferenceOnlyRestricts() + + /** + * On MySQL and MariaDB a key reference is searched by comparing the column, never a quoted string. + * + * A double-quoted identifier is a string literal there (no ANSI_QUOTES): `"code" = ?` is + * `'code' = ?`, false for every row, so the delete went through. The identifier is + * quoted by the platform (backticks), the column is the one the schema's table was + * built with, and the table carries the instance's prefix. + * + * @return void + */ + public function testAScalarKeyIsComparedAsAColumnOnMySql(): void { + $this->schemas(labelsProperty: ['type' => 'string', 'referenceSchema' => 'label', 'referenceKey' => 'code', 'onDelete' => 'RESTRICT']); + $params = []; + $this->citingRows(rows: [['_uuid' => 'note-1', '_register' => '7', '_schema' => '2', '_deleted' => null, '_prop' => 'A1']], params: $params); + + $analysis = $this->service->canDelete($this->label(code: 'A1')); + + self::assertFalse($analysis->deletable); + self::assertStringContainsString('`labels_1` = ?', $this->sql); + self::assertStringNotContainsString('"', $this->sql, 'No double-quoted identifier: MySQL reads it as a string.'); + self::assertStringContainsString('FROM `oc_openregister_table_7_2`', $this->sql); + self::assertSame(['A1'], $params); + }//end testAScalarKeyIsComparedAsAColumnOnMySql() + + /** + * On PostgreSQL the same query quotes with double quotes, the platform's own way. + * + * @return void + */ + public function testAScalarKeyIsComparedAsAColumnOnPostgres(): void { + $this->reported = new PostgreSQLPlatform(); + $this->schemas(labelsProperty: ['type' => 'string', 'referenceSchema' => 'label', 'referenceKey' => 'code', 'onDelete' => 'RESTRICT']); + $params = []; + $this->citingRows(rows: [], params: $params); + + self::assertTrue($this->service->canDelete($this->label(code: 'A1'))->deletable); + self::assertStringContainsString('"labels_1" = ?', $this->sql); + self::assertStringContainsString('FROM "oc_openregister_table_7_2"', $this->sql); + }//end testAScalarKeyIsComparedAsAColumnOnPostgres() + + /** + * A property the citing table has no column for cannot be checked, so the delete is refused. + * + * @return void + */ + public function testAPropertyWithoutAColumnFailsClosed(): void { + $this->schemas(labelsProperty: ['type' => 'array', 'referenceSchema' => 'label', 'referenceKey' => 'code', 'onDelete' => 'RESTRICT'], columns: []); + $this->db->expects(self::never())->method('prepare'); + $this->db->method('tableExists')->willReturn(true); + + self::assertSame('unverifiable', $this->service->canDelete($this->label(code: 'A1'))->blockers[0]['reason']); + }//end testAPropertyWithoutAColumnFailsClosed() }//end class diff --git a/tests/Unit/Service/Object/SaveObjectCarriedVersionTest.php b/tests/Unit/Service/Object/SaveObjectCarriedVersionTest.php new file mode 100644 index 0000000000..b85d69fcb9 --- /dev/null +++ b/tests/Unit/Service/Object/SaveObjectCarriedVersionTest.php @@ -0,0 +1,93 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Object; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Object\ObjectVersionHandler; +use OCA\OpenRegister\Service\Object\SaveObject; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionMethod; + +/** + * The seed channel (a stored seed follows a higher shipped version) must not be steerable from the object API. + * + * @covers \OCA\OpenRegister\Service\Object\SaveObject + * @covers \OCA\OpenRegister\Service\Object\ObjectVersionHandler + * @uses \OCA\OpenRegister\Db\ObjectEntity + */ +class SaveObjectCarriedVersionTest extends TestCase { + + /** + * The `@self` an update works from, for a caller judged by RBAC or not. + * + * @param array $selfData The `@self` the caller sent. + * @param bool $rbac Whether RBAC applies to the save. + * + * @return array The `@self` the update uses. + */ + private function selfDataForUpdate(array $selfData, bool $rbac): array { + $save = (new ReflectionClass(SaveObject::class))->newInstanceWithoutConstructor(); + + return (new ReflectionMethod(SaveObject::class, 'selfDataForUpdate'))->invoke($save, $selfData, $rbac); + }//end selfDataForUpdate() + + /** + * The version an update ends on, given the `@self` it works from. + * + * @param array $selfData The `@self` the update works from. + * + * @return string|null The stored version afterwards. + */ + private function versionAfter(array $selfData): ?string { + $entity = new ObjectEntity(); + $entity->setVersion('1.0.3'); + $carried = null; + if (is_scalar($selfData['version'] ?? null) === true) { + $carried = (string)$selfData['version']; + } + + (new ObjectVersionHandler())->advanceVersion(entity: $entity, carried: $carried); + + return $entity->getVersion(); + }//end versionAfter() + + /** + * A caller judged by RBAC cannot set a higher version: the update bumps the patch like any other. + * + * Goes red without the filter: the object would end on 9.9.9 and the + * next shipped version of the seed would never be taken. + * + * @return void + */ + public function testAUserCannotSetAHigherVersion(): void { + $selfData = $this->selfDataForUpdate(selfData: ['version' => '9.9.9', 'slug' => 'woo'], rbac: true); + + $this->assertArrayNotHasKey('version', $selfData); + $this->assertSame('woo', $selfData['slug'], 'Nothing else is touched.'); + $this->assertSame('1.0.4', $this->versionAfter(selfData: $selfData)); + }//end testAUserCannotSetAHigherVersion() + + /** + * The importer, an administrator and a system caller (RBAC off) keep the version they carry. + * + * @return void + */ + public function testATrustedCallerKeepsTheCarriedVersion(): void { + $selfData = $this->selfDataForUpdate(selfData: ['version' => '9.9.9'], rbac: false); + + $this->assertSame(['version' => '9.9.9'], $selfData); + $this->assertSame('9.9.9', $this->versionAfter(selfData: $selfData)); + }//end testATrustedCallerKeepsTheCarriedVersion() +}//end class diff --git a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php index 3e6f93ae65..f2c77ce40a 100644 --- a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php +++ b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php @@ -326,6 +326,118 @@ 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() + + /** + * 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. + */ + 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. + */ + 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); diff --git a/tests/Unit/Service/ObjectServiceBulkDeleteTest.php b/tests/Unit/Service/ObjectServiceBulkDeleteTest.php index bec2568713..95233f429d 100644 --- a/tests/Unit/Service/ObjectServiceBulkDeleteTest.php +++ b/tests/Unit/Service/ObjectServiceBulkDeleteTest.php @@ -54,6 +54,9 @@ use OCA\OpenRegister\Service\Object\DeleteObject; use OCA\OpenRegister\Service\Object\PermissionHandler; use OCA\OpenRegister\Service\ObjectService; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -300,4 +303,131 @@ public function testRowOnANonArchivalSchemaIsNotRefused(): void { $this->assertSame(['uuid-archival'], $result['deleted_uuids']); $this->assertSame([], $result['skipped_uuids']); }//end testRowOnANonArchivalSchemaIsNotRefused() + + /** + * Sign a user in on the service under test. + * + * @param string $uid The user. + * @param array $groups The user's groups. + * + * @return void + */ + private function signIn(string $uid, array $groups=['staff']): void { + $user = $this->createMock(IUser::class); + $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); + $this->inject('userSession', $session); + $this->inject('groupManager', $groupManager); + }//end signIn() + + /** + * A stored draft of a user. + * + * @param string $uuid The uuid. + * @param string $owner The owner. + * @param int $schemaId The schema. + * @param array $configuration The schema's configuration. + * + * @return ObjectEntity The draft. + */ + private function givenDraft(string $uuid, string $owner, int $schemaId, array $configuration=[]): ObjectEntity { + $draft = $this->givenObject(uuid: $uuid, schemaId: $schemaId, configuration: $configuration); + $draft->setStatus('draft'); + $draft->setOwner($owner); + + return $draft; + }//end givenDraft() + + /** + * Bulk-deleting somebody else's draft skips it and never reaches the delete handler. + * + * The permission filter is doubled to let it through here, so this is the + * loop's own answer; it goes red with the loop check removed. + * + * @return void + */ + public function testSomebodyElsesDraftIsSkippedByTheLoop(): void { + $draft = $this->givenDraft(uuid: 'uuid-draft', owner: 'alice', schemaId: 11); + $this->buildService([$draft]); + $this->signIn(uid: 'bob'); + + $this->deleteHandler->expects($this->never())->method('deleteObject'); + + $result = $this->service->deleteObjects(['uuid-draft']); + + $this->assertSame([], $result['deleted_uuids']); + $this->assertSame(['uuid-draft'], $result['skipped_uuids']); + $this->assertSame('OBJECT_NOT_PERMITTED', $result['skipped_reasons']['uuid-draft']['error'] ?? null); + }//end testSomebodyElsesDraftIsSkippedByTheLoop() + + /** + * The owner bulk-discards their own draft for good, also on an archival schema. + * + * A draft is not a record: the archival gate does not apply, and the delete + * is permanent, so no tombstone of the unsent input stays and the handler + * writes the one `draft.removed` entry. Goes red without the permanent flag. + * + * @return void + */ + public function testTheOwnersDraftIsRemovedPermanentlyOnAnArchivalSchema(): void { + $draft = $this->givenDraft(uuid: 'uuid-draft', owner: 'alice', schemaId: 12, configuration: self::ARCHIVAL_CONFIGURATION); + $this->buildService([$draft]); + $this->signIn(uid: 'alice'); + + $permanentFlags = []; + $this->deleteHandler->method('deleteObject')->willReturnCallback( + static function (mixed ...$args) use (&$permanentFlags): bool { + $permanentFlags[] = ($args[8] ?? null); + + return true; + } + ); + + $result = $this->service->deleteObjects(['uuid-draft']); + + $this->assertSame(['uuid-draft'], $result['deleted_uuids']); + $this->assertSame([true], $permanentFlags, 'a draft is deleted permanently'); + }//end testTheOwnersDraftIsRemovedPermanentlyOnAnArchivalSchema() + + /** + * An administrator bulk-deletes another user's draft, as on the read path. + * + * @return void + */ + public function testAnAdministratorMayBulkDeleteAnyDraft(): void { + $draft = $this->givenDraft(uuid: 'uuid-draft', owner: 'alice', schemaId: 11); + $this->buildService([$draft]); + $this->signIn(uid: 'root', groups: ['admin']); + + $this->deleteHandler->expects($this->once())->method('deleteObject')->willReturn(true); + + $this->assertSame(['uuid-draft'], $this->service->deleteObjects(['uuid-draft'])['deleted_uuids']); + }//end testAnAdministratorMayBulkDeleteAnyDraft() + + /** + * A definitive object is still deleted softly, and still refused on an archival schema. + * + * @return void + */ + public function testARecordIsNotDeletedPermanently(): void { + $record = $this->givenObject(uuid: 'uuid-record', schemaId: 11); + $record->setStatus('active'); + $this->buildService([$record]); + + $permanentFlags = []; + $this->deleteHandler->method('deleteObject')->willReturnCallback( + static function (mixed ...$args) use (&$permanentFlags): bool { + $permanentFlags[] = ($args[8] ?? null); + + return true; + } + ); + + $this->service->deleteObjects(['uuid-record']); + + $this->assertSame([false], $permanentFlags); + }//end testARecordIsNotDeletedPermanently() }//end class diff --git a/tests/Unit/Service/ObjectServiceDraftDeleteTest.php b/tests/Unit/Service/ObjectServiceDraftDeleteTest.php index 4f79a3350b..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), @@ -337,6 +343,94 @@ 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() + + /** + * 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. * diff --git a/tests/Unit/Service/Portal/PortalTaskServiceTest.php b/tests/Unit/Service/Portal/PortalTaskServiceTest.php index 3c7a330076..f3f4ae4b7d 100644 --- a/tests/Unit/Service/Portal/PortalTaskServiceTest.php +++ b/tests/Unit/Service/Portal/PortalTaskServiceTest.php @@ -509,4 +509,76 @@ public function testOnlyAWholeCompleteViaCounts(): void { $task->setMetadata([PortalTaskService::COMPLETE_VIA => ['app' => ' dossiq ', 'action' => 'completeChecklistTask']]); $this->assertSame(['app' => 'dossiq', 'action' => 'completeChecklistTask'], $this->service->completeVia(task: $task)); }//end testOnlyAWholeCompleteViaCounts() + + /** + * A task held back by an open blocker cannot be completed by its party, and nothing is stored. + * + * @return void + */ + public function testATaskWaitingOnAnOpenBlockerCannotBeCompletedThroughThePortal(): void { + $task = $this->task(['required' => false]); + $task->setBlockedBy('t-blocker'); + $this->openForRuns($task); + $blocker = new Task(); + $blocker->setUuid('t-blocker'); + $blocker->setIsTerminal(false); + $this->mapper->method('findByUuid')->with('t-blocker')->willReturn($blocker); + $this->files->expects($this->never())->method('addFile'); + $this->tasks->expects($this->never())->method('complete'); + + $this->expectException(DoesNotExistException::class); + $this->service->complete(subject: new PortalSubject(subjectRef: 'bsn-1'), uuid: 't-1'); + }//end testATaskWaitingOnAnOpenBlockerCannotBeCompletedThroughThePortal() + + /** + * Once the blocker is closed (or gone) the task is the party's again. + * + * @return void + */ + public function testATaskWhoseBlockerIsClosedIsCompletedAsBefore(): void { + $task = $this->task(['required' => false]); + $task->setBlockedBy('t-blocker'); + $this->openForRuns($task); + $blocker = new Task(); + $blocker->setUuid('t-blocker'); + $blocker->setIsTerminal(true); + $this->mapper->method('findByUuid')->willReturn($blocker); + $this->tasks->expects($this->once())->method('complete')->willReturn($task); + + $this->service->complete(subject: new PortalSubject(subjectRef: 'bsn-1'), uuid: 't-1'); + }//end testATaskWhoseBlockerIsClosedIsCompletedAsBefore() + + /** + * A blocked portal task is not shown by its uuid either: it reads like one that does not exist. + * + * @return void + */ + public function testABlockedPortalTaskIsNotShown(): void { + $task = $this->task(); + $task->setBlockedBy('t-blocker'); + $this->tasks->method('get')->willReturn($task); + $blocker = new Task(); + $blocker->setIsTerminal(false); + $this->mapper->method('findByUuid')->willReturn($blocker); + + $this->expectException(DoesNotExistException::class); + $this->service->show(subject: new PortalSubject(subjectRef: 'bsn-1'), uuid: 't-1'); + }//end testABlockedPortalTaskIsNotShown() + + /** + * The list reads its page with the blocker exclusion, so no row runs its own blocker query. + * + * @return void + */ + public function testThePortalListMarksNoRowBlockedAndAsksForNoBlockerPerRow(): void { + $task = $this->task(); + $this->mapper->method('findOpenExternalForParty')->willReturn([$task]); + $this->mapper->method('countOpenExternalForParty')->willReturn(1); + $this->mapper->expects($this->never())->method('findByUuid'); + + $page = $this->service->listForSubject(subject: new PortalSubject(subjectRef: 'bsn-1')); + + $this->assertSame(1, $page['total']); + $this->assertFalse($page['results'][0]['blocked']); + }//end testThePortalListMarksNoRowBlockedAndAsksForNoBlockerPerRow() }//end class diff --git a/tests/Unit/Service/Rbac/EffectiveAuthorizationTest.php b/tests/Unit/Service/Rbac/EffectiveAuthorizationTest.php new file mode 100644 index 0000000000..f0b404c596 --- /dev/null +++ b/tests/Unit/Service/Rbac/EffectiveAuthorizationTest.php @@ -0,0 +1,102 @@ + + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Rbac + * + * @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/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Rbac; + +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Service\Rbac\EffectiveAuthorization; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Service\Rbac\EffectiveAuthorization + */ +class EffectiveAuthorizationTest extends TestCase { + + /** + * The effective authorization of a schema whose block names roles. + * + * @param array $block The schema's authorization block. + * @param array|null $roles The register's role definitions; null for no register. + * + * @return array|null The answer. + */ + private function forSchema(array $block, ?array $roles): ?array { + $schema = new Schema(); + $schema->setId(5); + $schema->setAuthorization($block); + + $mapper = $this->createMock(RegisterMapper::class); + if ($roles === null) { + $mapper->method('getFirstRegisterWithSchema')->willReturn(null); + } else { + $register = new Register(); + $register->setId(1); + $register->setConfiguration(['roles' => $roles]); + $mapper->method('getFirstRegisterWithSchema')->willReturn(1); + $mapper->method('find')->willReturn($register); + } + + return (new EffectiveAuthorization(registerMapper: $mapper))->forSchema(schema: $schema); + }//end forSchema() + + /** + * A roles block over a register without role definitions grants nobody anything. + * + * @return void + */ + public function testARolesBlockWithoutRoleDefinitionsGrantsNothing(): void { + $expected = ['read' => [], 'create' => [], 'update' => [], 'delete' => []]; + + $this->assertSame($expected, $this->forSchema(block: ['roles' => ['viewer' => ['staff']]], roles: [])); + $this->assertSame($expected, $this->forSchema(block: ['roles' => ['viewer' => ['staff']]], roles: null)); + }//end testARolesBlockWithoutRoleDefinitionsGrantsNothing() + + /** + * A roles block whose every name is unknown grants nobody anything. + * + * @return void + */ + public function testARolesBlockWithOnlyUnknownRolesGrantsNothing(): void { + $answer = $this->forSchema( + block: ['roles' => ['ghost' => ['staff']]], + roles: [['name' => 'viewer', 'actions' => ['read']]] + ); + + $this->assertSame(['read' => [], 'create' => [], 'update' => [], 'delete' => []], $answer); + }//end testARolesBlockWithOnlyUnknownRolesGrantsNothing() + + /** + * A roles block that resolves still expands to the verbs of its roles. + * + * @return void + */ + public function testARolesBlockThatResolvesIsExpanded(): void { + $answer = $this->forSchema( + block: ['roles' => ['viewer' => ['staff']]], + roles: [['name' => 'viewer', 'actions' => ['read']]] + ); + + $this->assertSame(['read' => ['staff']], $answer); + }//end testARolesBlockThatResolvesIsExpanded() +}//end class diff --git a/tests/Unit/Service/Rbac/GrantChangeAuditorTest.php b/tests/Unit/Service/Rbac/GrantChangeAuditorTest.php new file mode 100644 index 0000000000..2988b2f3ed --- /dev/null +++ b/tests/Unit/Service/Rbac/GrantChangeAuditorTest.php @@ -0,0 +1,144 @@ + + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Rbac + * + * @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/special-right-to-manage-grants/specs/object-ownership/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Rbac; + +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Rbac\GrantChangeAuditor; +use OCA\OpenRegister\Service\Rbac\GrantManageGuard; +use OCA\OpenRegister\Service\Rbac\SettingsChangeAuditor; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * @covers \OCA\OpenRegister\Service\Rbac\GrantChangeAuditor + */ +class GrantChangeAuditorTest extends TestCase { + + private AuditTrailMapper&MockObject $mapper; + + private SettingsChangeAuditor&MockObject $settings; + + private LoggerInterface&MockObject $logger; + + private GrantChangeAuditor $auditor; + + protected function setUp(): void { + parent::setUp(); + + $this->mapper = $this->createMock(AuditTrailMapper::class); + $this->settings = $this->createMock(SettingsChangeAuditor::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->auditor = new GrantChangeAuditor($this->mapper, $this->settings, $this->logger); + }//end setUp() + + /** + * A record. + * + * @return ObjectEntity The record. + */ + private function record(): ObjectEntity { + $object = new ObjectEntity(); + $object->setUuid('record-1'); + + return $object; + }//end record() + + public function testAGrantIsOneTypedEntryNamingThePrincipalAndHowTheCallerWasAdmitted(): void { + $record = $this->record(); + $this->mapper->expects($this->once())->method('createAuditTrailEntry')->with( + $record, + GrantChangeAuditor::ACTION_GRANTED, + [ + 'grant' => 'ocinternal:9', + 'type' => 'user', + 'sharedWith' => 'reviewer', + 'permissions' => 1, + 'verbs' => ['use'], + 'admittedAs' => GrantManageGuard::AS_HOLDER, + ] + ); + + $this->auditor->recordGrant( + object: $record, + grant: ['id' => 'ocinternal:9', 'type' => 'user', 'sharedWith' => 'reviewer', 'permissions' => 1, 'verbs' => ['use']], + admittedAs: GrantManageGuard::AS_HOLDER + ); + }//end testAGrantIsOneTypedEntryNamingThePrincipalAndHowTheCallerWasAdmitted() + + public function testARevokeIsItsOwnAction(): void { + $this->mapper->expects($this->once())->method('createAuditTrailEntry')->with( + $this->anything(), + GrantChangeAuditor::ACTION_REVOKED, + $this->callback(static fn (array $changed): bool => $changed['sharedWith'] === 'reviewer' && $changed['admittedAs'] === GrantManageGuard::AS_OWNER) + ); + + $this->auditor->recordRevoke( + object: $this->record(), + grant: ['id' => 'ocinternal:9', 'type' => 'user', 'sharedWith' => 'reviewer', 'permissions' => 1], + admittedAs: GrantManageGuard::AS_OWNER + ); + }//end testARevokeIsItsOwnAction() + + public function testAChangeOfTheOwningGroupNamesBothSides(): void { + $this->mapper->expects($this->once())->method('createAuditTrailEntry')->with( + $this->anything(), + GrantChangeAuditor::ACTION_OWNER_GROUP, + ['ownerGroup' => ['old' => null, 'new' => 'supervisors']] + ); + + $this->auditor->recordOwnerGroup(object: $this->record(), before: null, after: 'supervisors'); + }//end testAChangeOfTheOwningGroupNamesBothSides() + + public function testNamingTheSameOwningGroupAgainIsNoChange(): void { + $this->mapper->expects($this->never())->method('createAuditTrailEntry'); + + $this->auditor->recordOwnerGroup(object: $this->record(), before: 'supervisors', after: 'supervisors'); + }//end testNamingTheSameOwningGroupAgainIsNoChange() + + public function testAChangeOfTheSpecialRightListIsASettingsChangeOnTheSameChain(): void { + $this->settings->expects($this->once())->method('recordUpdate')->with( + 'openregister', + ['organisation org-1: grant_manage' => ['records-service']], + ['organisation org-1: grant_manage' => ['records-service', 'second-service']] + ); + + $this->auditor->recordSpecialRight( + organisationUuid: 'org-1', + before: ['records-service'], + after: ['records-service', 'second-service'] + ); + }//end testAChangeOfTheSpecialRightListIsASettingsChangeOnTheSameChain() + + public function testAnEntryThatCannotBeWrittenIsLoggedAndDoesNotFailTheGrant(): void { + $this->mapper->method('createAuditTrailEntry')->willThrowException(new \RuntimeException('chain unavailable')); + $this->logger->expects($this->once())->method('error')->with($this->stringContains('Could not record'), $this->anything()); + + $this->auditor->recordGrant(object: $this->record(), grant: ['id' => 'x'], admittedAs: null); + }//end testAnEntryThatCannotBeWrittenIsLoggedAndDoesNotFailTheGrant() +}//end class diff --git a/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php b/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php index 1a4f67f398..bb476ec7b8 100644 --- a/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php +++ b/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php @@ -45,6 +45,7 @@ use OCA\OpenRegister\Service\File\FolderManagementHandler; use OCA\OpenRegister\Service\Object\PermissionHandler; use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\Rbac\GrantChangeAuditor; use OCA\OpenRegister\Service\Rbac\GrantManageGuard; use OCA\OpenRegister\Service\Rbac\InheritedGrantLister; use OCA\OpenRegister\Service\Rbac\ObjectAuthorizationWriter; @@ -54,6 +55,7 @@ use OCA\OpenRegister\Service\Rbac\ObjectScopeResolver; use OCA\OpenRegister\Service\Rbac\ObjectSharingService; use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights; +use OCA\OpenRegister\Service\Rbac\SettingsChangeAuditor; use OCP\Files\Folder; use OCP\IGroupManager; use OCP\IRequest; @@ -84,6 +86,8 @@ class GrantManageSpecialRightTest extends TestCase { private IRequest&MockObject $request; + private AuditTrailMapper&MockObject $trail; + private ObjectSharingService $sharing; private ObjectOwnershipService $ownership; @@ -136,6 +140,9 @@ static function (string|int $id): Schema { $folders = $this->createMock(FolderManagementHandler::class); $folders->method('getObjectFolder')->willReturn($folder); + $this->trail = $this->createMock(AuditTrailMapper::class); + $auditor = new GrantChangeAuditor($this->trail, $this->createMock(SettingsChangeAuditor::class), new NullLogger()); + $this->sharing = new ObjectSharingService( $this->authorizationWriter, $this->userSession, @@ -146,7 +153,8 @@ static function (string|int $id): Schema { $this->shareManager, new NullLogger(), $this->createMock(InheritedGrantLister::class), - new GrantManageGuard(new ObjectScopeResolver(), $specialRights) + new GrantManageGuard(new ObjectScopeResolver(), $specialRights), + $auditor ); $this->ownership = new ObjectOwnershipService( @@ -162,7 +170,8 @@ static function (string|int $id): Schema { $registers, $schemas, $this->createMock(INotificationManager::class), - new NullLogger() + new NullLogger(), + $auditor ); $this->record = $this->record(schema: '12'); @@ -250,6 +259,51 @@ public function testAHolderWritesListsAndRevokesAGrantOnARecordItDoesNotOwn(): v $this->sharing->revoke($this->record, 'ocinternal:9'); }//end testAHolderWritesListsAndRevokesAGrantOnARecordItDoesNotOwn() + public function testEveryGrantAndRevokeLeavesAnAuditRowNamingHowTheCallerWasAdmitted(): void { + $this->signIn('service-account', [self::SERVICE_GROUP]); + $share = $this->shareManagerStoresAShare(); + $share->method('getShareType')->willReturn(IShare::TYPE_USER); + + $rows = []; + $this->trail->expects($this->exactly(2))->method('createAuditTrailEntry')->willReturnCallback( + function (ObjectEntity $object, string $action, array $context) use (&$rows) { + $rows[] = [$object->getUuid(), $action, $context]; + + return new \OCA\OpenRegister\Db\AuditTrail(); + } + ); + + $this->sharing->grant($this->record, 'user', 'reviewer', 1); + $this->sharing->revoke($this->record, 'ocinternal:9'); + + $this->assertSame('record-1', $rows[0][0]); + $this->assertSame(GrantChangeAuditor::ACTION_GRANTED, $rows[0][1]); + $this->assertSame('reviewer', $rows[0][2]['sharedWith']); + $this->assertSame(GrantManageGuard::AS_HOLDER, $rows[0][2]['admittedAs']); + $this->assertSame(GrantChangeAuditor::ACTION_REVOKED, $rows[1][1]); + $this->assertSame('user', $rows[1][2]['type']); + $this->assertSame('reviewer', $rows[1][2]['sharedWith']); + }//end testEveryGrantAndRevokeLeavesAnAuditRowNamingHowTheCallerWasAdmitted() + + public function testARefusedGrantLeavesNoAuditRow(): void { + $this->signIn('service-account', [self::SERVICE_GROUP]); + $this->trail->expects($this->never())->method('createAuditTrailEntry'); + + $this->expectException(NotAuthorizedException::class); + $this->sharing->grant($this->record, 'user', 'service-account', 1); + }//end testARefusedGrantLeavesNoAuditRow() + + public function testANewOwningGroupLeavesAnAuditRowWithBothSides(): void { + $this->signIn('service-account', [self::SERVICE_GROUP]); + $this->trail->expects($this->once())->method('createAuditTrailEntry')->with( + $this->record, + GrantChangeAuditor::ACTION_OWNER_GROUP, + ['ownerGroup' => ['old' => null, 'new' => 'supervisors']] + ); + + $this->ownership->setOwnerGroup(new Register(), new Schema(), $this->record, 'supervisors'); + }//end testANewOwningGroupLeavesAnAuditRowWithBothSides() + public function testAHolderNamesTheOwningGroup(): void { $this->signIn('service-account', [self::SERVICE_GROUP]); $this->authorizationWriter->expects($this->once())->method('writeAuthorizationBlock'); @@ -314,6 +368,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() diff --git a/tests/Unit/Service/RegisterDescriptorServiceTest.php b/tests/Unit/Service/RegisterDescriptorServiceTest.php index afb2b0af89..5e0ad652d7 100644 --- a/tests/Unit/Service/RegisterDescriptorServiceTest.php +++ b/tests/Unit/Service/RegisterDescriptorServiceTest.php @@ -28,6 +28,7 @@ use OCA\OpenRegister\Service\AppRegisterImportRunner; use OCA\OpenRegister\Service\ConfigurationService; use OCA\OpenRegister\Service\RegisterDescriptorService; +use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds; use OCA\OpenRegister\Service\CountedRegisterImport; use OCA\OpenRegister\Service\ShippedBaseline\ShippedAuthorizationDecision; use Psr\Container\ContainerInterface; @@ -754,4 +755,42 @@ public function testAnAppWithoutABaseDocumentImportsTheDescriptorDeclaringTheSlu $this->assertSame(RegisterDescriptorService::PATH_DESCRIPTOR, $result['path']); $this->assertStringContainsString('flow_register.json', $result['via']); } + + /** + * A core descriptor is re-imported under the configuration id its repair + * step imports it under, never the shared `openregister` id: that would set + * the version the force:false repair steps of other descriptors compare + * against. + * + * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md + */ + public function testACoreDescriptorIsReimportedUnderItsInstallTimeConfigurationId(): void { + $this->writeDescriptor(file: 'merge_operation_register.json', slug: 'merge-operation', version: '1.4.0'); + $this->expectApp('openregister'); + $this->registerMapper->method('findAll')->willReturn([$this->register('merge-operation', '1.4.0')]); + + $this->configurationService->expects($this->once()) + ->method('importFromApp') + ->with($this->equalTo('openregister.merge-operation'), $this->anything(), $this->equalTo('1.4.0'), $this->isTrue()) + ->willReturn(['registers' => ['merge-operation'], 'importJobId' => 'job-7']); + + $result = $this->service->reimport(appId: 'openregister', slug: 'merge-operation'); + + $this->assertSame('imported', $result['outcome']); + $this->assertSame('job-7', $result['importJobId'], 'the job the import ran under is reported'); + $this->assertSame('openregister.merge-operation', $result['importAppId']); + } + + /** + * A descriptor whose repair step imports under the shared id keeps it, and + * another app's file never takes a core descriptor's id. + * + * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md + */ + public function testADescriptorWithoutADedicatedIdAndAnotherAppsFileKeepTheirOwnId(): void { + $this->assertSame('openregister', DescriptorConfigIds::forFile(appId: 'openregister', file: 'flow_register.json')); + $this->assertSame('openregister', DescriptorConfigIds::forFile(appId: 'openregister', file: 'brp_register.json')); + $this->assertSame('fixtureapp', DescriptorConfigIds::forFile(appId: 'fixtureapp', file: 'merge_operation_register.json')); + $this->assertSame('openregister.dsar-policy-packs', DescriptorConfigIds::forFile(appId: 'openregister', file: 'dsar_policy_pack_register.json')); + } } diff --git a/tests/Unit/Service/Relation/ObjectTreeServiceTest.php b/tests/Unit/Service/Relation/ObjectTreeServiceTest.php index 9f20cc8830..494ebce562 100644 --- a/tests/Unit/Service/Relation/ObjectTreeServiceTest.php +++ b/tests/Unit/Service/Relation/ObjectTreeServiceTest.php @@ -35,6 +35,7 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Object\RenderObject; use OCA\OpenRegister\Service\Party\PartyAnnotationValidator; use OCA\OpenRegister\Service\Rbac\HierarchyAnnotationValidator; use OCA\OpenRegister\Service\Rbac\HierarchyDescender; @@ -60,6 +61,27 @@ */ class ObjectTreeServiceTest extends TestCase { + /** + * The `limit` every `childrenOf` call of the last built service was given. + * + * @var array + */ + private array $askedLimits = []; + + /** + * Whether the access check of the last built service fails instead of answering. + * + * @var boolean + */ + private bool $accessFails = false; + + /** + * The redaction the service is given, or null for one that changes nothing. + * + * @var RenderObject|null + */ + private ?RenderObject $renderer = null; + /** * An organisation schema fragment, as a register file declares it. * @@ -136,9 +158,10 @@ private function service(array $parentOf, ?array $readable = null, ?array $fragm ->onlyMethods(['childrenOf']) ->getMock(); $descender->method('childrenOf')->willReturnCallback( - function (string $table, string $parentColumn, array $parentUuids) use ($parentOf): array { + function (string $table, string $parentColumn, array $parentUuids, ?int $limit = null) use ($parentOf): array { $this->assertSame('openregister_table_1_2', $table); $this->assertSame('parent_organisation', $parentColumn); + $this->askedLimits[] = $limit; $children = []; foreach ($parentOf as $child => $parent) { if ($parent !== null && $child !== $parent && in_array($parent, $parentUuids, true) === true) { @@ -146,13 +169,13 @@ function (string $table, string $parentColumn, array $parentUuids) use ($parentO } } - return $children; + return ($limit === null ? $children : array_slice($children, 0, $limit, true)); } ); $objectMapper = $this->createMock(MagicMapper::class); - $objectMapper->method('filterReadableUuids')->willReturnCallback( - static fn (int $registerId, int $schemaId, array $uuids): array => array_values( + $objectMapper->method('readableUuidsOrNull')->willReturnCallback( + fn (int $registerId, int $schemaId, array $uuids): ?array => ($this->accessFails === true) ? null : array_values( array_filter($uuids, static fn (string $uuid): bool => $readable === null || in_array($uuid, $readable, true)) ) ); @@ -197,6 +220,7 @@ function (Register $register, Schema $schema, array $uuids) use ($parentOf): arr registerMapper: $registerMapper, schemaMapper: $schemaMapper, graphs: $graphs, + renderer: ($this->renderer ?? $this->createMock(RenderObject::class)), logger: $logger ); }//end service() @@ -204,18 +228,24 @@ function (Register $register, Schema $schema, array $uuids) use ($parentOf): arr /** * One organisation object. * - * @param string $uuid The uuid. - * @param string|null $parent The parent uuid. + * @param string $uuid The uuid. + * @param string|null $parent The parent uuid. + * @param array $extra Further properties. * * @return ObjectEntity The object. */ - private function organisation(string $uuid, ?string $parent): ObjectEntity { + private function organisation(string $uuid, ?string $parent, array $extra = []): ObjectEntity { $entity = new ObjectEntity(); $entity->setUuid($uuid); $entity->setName('Org '.$uuid); $entity->setRegister('1'); $entity->setSchema('2'); - $entity->setObject(['tradeName' => 'Org '.$uuid, 'kvkNumber' => '1000000'.strlen($uuid), 'parentOrganisation' => $parent]); + $entity->setObject( + array_merge( + ['tradeName' => 'Org '.$uuid, 'kvkNumber' => '1000000'.strlen($uuid), 'parentOrganisation' => $parent], + $extra + ) + ); return $entity; }//end organisation() @@ -402,4 +432,148 @@ public function testTheHierarchyDeclarationIsReadToo(): void { $this->assertSame(1, $tree['maxDepth']); $this->assertSame(['holding' => 0, 'sub-a' => 1, 'sub-b' => 1], self::levels(tree: $tree)); }//end testTheHierarchyDeclarationIsReadToo() + + /** + * The `fields` of a node never carry a write-only, a property-restricted or an encrypted value. + * + * The service hands every entity it reads to the same redaction every other + * read of an object goes through, and reads the properties from what comes + * back; the objects the mapper loaded are left as they were. + * + * @return void + */ + public function testFieldsNeverReturnWhatTheCallerMayNotRead(): void { + $redactions = 0; + $renderer = $this->createMock(RenderObject::class); + $renderer->method('redactWriteOnlyFromRows')->willReturnCallback( + function (array &$rows, bool $_rbac = true) use (&$redactions): void { + foreach ($rows as $row) { + $redactions++; + $data = $row->getObject(); + unset($data['secret'], $data['restricted']); + $row->setObject($data); + } + } + ); + $this->renderer = $renderer; + + $root = $this->organisation(uuid: 'sub-a', parent: 'holding', extra: ['secret' => 's3cret', 'restricted' => 'no', 'open' => 'yes']); + $tree = $this->service(parentOf: self::group())->tree( + root: $root, + direction: ObjectTreeService::DIRECTION_BOTH, + fields: ['secret', 'restricted', 'open'] + ); + + $this->assertGreaterThanOrEqual(3, $redactions); + $this->assertCount(3, $tree['nodes']); + foreach ($tree['nodes'] as $node) { + $this->assertNull($node['properties']['secret'], 'node '.$node['uuid']); + $this->assertNull($node['properties']['restricted'], 'node '.$node['uuid']); + } + + $this->assertSame('yes', $tree['nodes'][1]['properties']['open']); + $this->assertSame('s3cret', $root->getObject()['secret'], 'The entity the caller passed in is not changed.'); + }//end testFieldsNeverReturnWhatTheCallerMayNotRead() + + /** + * A request that names no field redacts nothing: there is nothing to carry. + * + * @return void + */ + public function testWithoutFieldsNothingIsRedacted(): void { + $renderer = $this->createMock(RenderObject::class); + $renderer->expects($this->never())->method('redactWriteOnlyFromRows'); + $this->renderer = $renderer; + + $tree = $this->service(parentOf: self::group())->tree(root: $this->organisation(uuid: 'holding', parent: null)); + + $this->assertCount(4, $tree['nodes']); + }//end testWithoutFieldsNothingIsRedacted() + + /** + * A failing access check is an incomplete answer, not a complete one without those nodes. + * + * @return void + */ + public function testAFailingAccessCheckReadsAsUnreadableNotAsComplete(): void { + $this->accessFails = true; + $service = $this->service(parentOf: self::group()); + + $down = $service->tree(root: $this->organisation(uuid: 'holding', parent: null)); + $this->assertSame(['holding' => 0], self::levels(tree: $down)); + $this->assertTrue($down['truncated']); + $this->assertSame(ObjectTreeService::TRUNCATED_UNREADABLE, $down['truncatedBy']); + + $up = $service->tree(root: $this->organisation(uuid: 'sub-a', parent: 'holding'), direction: ObjectTreeService::DIRECTION_ANCESTORS); + $this->assertSame(['sub-a' => 0], self::levels(tree: $up)); + $this->assertTrue($up['truncated']); + $this->assertSame(ObjectTreeService::TRUNCATED_UNREADABLE, $up['truncatedBy']); + }//end testAFailingAccessCheckReadsAsUnreadableNotAsComplete() + + /** + * The node cap bounds the read of a level, not only the answer. + * + * @return void + */ + public function testTheCapBoundsTheRowsAskedFor(): void { + $wide = ['holding' => null]; + for ($i = 1; $i <= 50; $i++) { + $wide['child-'.$i] = 'holding'; + } + + $tree = $this->service(parentOf: $wide, cap: 10)->tree(root: $this->organisation(uuid: 'holding', parent: null)); + + $this->assertSame(ObjectTreeService::TRUNCATED_CAP, $tree['truncatedBy']); + $this->assertSame(['holding' => 0], self::levels(tree: $tree)); + $this->assertNotEmpty($this->askedLimits); + foreach ($this->askedLimits as $limit) { + $this->assertNotNull($limit); + $this->assertLessThanOrEqual(11, $limit); + } + }//end testTheCapBoundsTheRowsAskedFor() + + /** + * A level exactly as wide as the cap allows is answered whole. + * + * @return void + */ + public function testALevelThatFitsTheCapIsNotTruncated(): void { + $tree = $this->service(parentOf: self::group(), cap: 4)->tree(root: $this->organisation(uuid: 'holding', parent: null)); + + $this->assertSame(['holding' => 0, 'sub-a' => 1, 'sub-b' => 1, 'sub-a1' => 2], self::levels(tree: $tree)); + $this->assertFalse($tree['truncated']); + }//end testALevelThatFitsTheCapIsNotTruncated() + + /** + * The topmost ancestor does not name a parent the caller may not read. + * + * @return void + */ + public function testTheTopmostAncestorDoesNotNameAHiddenParent(): void { + $tree = $this->service(parentOf: self::group(), readable: ['sub-a1', 'sub-a'])->tree( + root: $this->organisation(uuid: 'sub-a1', parent: 'sub-a'), + direction: ObjectTreeService::DIRECTION_ANCESTORS + ); + + $this->assertSame(['sub-a' => -1, 'sub-a1' => 0], self::levels(tree: $tree)); + $this->assertNull($tree['nodes'][0]['parent']); + $this->assertFalse($tree['truncated']); + }//end testTheTopmostAncestorDoesNotNameAHiddenParent() + + /** + * At the depth bound the topmost node names the readable parent just above it. + * + * @return void + */ + public function testTheAncestorAtTheDepthBoundNamesItsReadableParent(): void { + $tree = $this->service(parentOf: self::group())->tree( + root: $this->organisation(uuid: 'sub-a1', parent: 'sub-a'), + direction: ObjectTreeService::DIRECTION_ANCESTORS, + depth: 1 + ); + + $this->assertSame(['sub-a' => -1, 'sub-a1' => 0], self::levels(tree: $tree)); + $this->assertSame('holding', $tree['nodes'][0]['parent']); + $this->assertSame(ObjectTreeService::TRUNCATED_DEPTH, $tree['truncatedBy']); + }//end testTheAncestorAtTheDepthBoundNamesItsReadableParent() }//end class diff --git a/tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php b/tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php index 735a609cb6..dfd784cc92 100644 --- a/tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php +++ b/tests/Unit/Service/Schemas/PropertyValidatorReferenceKeyTest.php @@ -22,6 +22,7 @@ namespace OCA\OpenRegister\Tests\Unit\Service\Schemas; use Exception; +use OCA\OpenRegister\Service\Object\ReferenceKey; use OCA\OpenRegister\Service\Schemas\PropertyValidatorHandler; use PHPUnit\Framework\TestCase; @@ -78,4 +79,44 @@ public function testOnDeleteWithoutTargetIsRefused(): void { (new PropertyValidatorHandler())->validateProperty(property: ['type' => 'string', 'referenceKey' => 'code', 'onDelete' => 'RESTRICT']); }//end testOnDeleteWithoutTargetIsRefused() + + /** + * `referenceKey: uuid` (or empty) is a plain uuid reference: every onDelete action applies, as the delete reads it. + * + * @return void + */ + public function testAUuidReferenceKeyIsAPlainReferenceAndAcceptsCascade(): void { + $validator = new PropertyValidatorHandler(); + + foreach (['uuid', ' uuid ', ''] as $key) { + self::assertTrue( + $validator->validateProperty( + property: ['type' => 'string', 'referenceSchema' => 'label', 'referenceKey' => $key, 'onDelete' => 'CASCADE'] + ), + 'referenceKey "' . $key . '"' + ); + } + + self::assertTrue( + $validator->validateProperty( + property: ['type' => 'array', 'items' => ['type' => 'string', 'referenceSchema' => 'label', 'referenceKey' => 'uuid'], 'onDelete' => 'SET_NULL'] + ) + ); + }//end testAUuidReferenceKeyIsAPlainReferenceAndAcceptsCascade() + + /** + * The validator and the delete read one declaration the same way. + * + * @return void + */ + public function testTheReaderAnswersTheKeyOrNullForAUuidReference(): void { + $reader = new ReferenceKey(); + + self::assertSame('code', $reader->keyOf(property: ['referenceKey' => ' code '])); + self::assertSame('code', $reader->keyOf(property: ['items' => ['referenceKey' => 'code']])); + self::assertNull($reader->keyOf(property: ['referenceKey' => 'uuid'])); + self::assertNull($reader->keyOf(property: ['referenceKey' => ''])); + self::assertNull($reader->keyOf(property: ['referenceKey' => 7])); + self::assertNull($reader->keyOf(property: [])); + }//end testTheReaderAnswersTheKeyOrNullForAUuidReference() }//end class diff --git a/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php b/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php index 78c103ad20..dd68633f34 100644 --- a/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php +++ b/tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php @@ -25,6 +25,7 @@ use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Dto\DeepLinkRegistration; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\DeepLinkRegistryService; use OCA\OpenRegister\Service\ObjectService; @@ -520,4 +521,42 @@ public function testAnUnfilledPlaceholderFallsBackToTheOpenRegisterRoute(): void $this->assertSame('/objects/u1', $entry->jsonSerialize()['resourceUrl']); }//end testAnUnfilledPlaceholderFallsBackToTheOpenRegisterRoute() + + /** + * An own property that is empty (or blank, or a boolean) must leave its + * placeholder unfilled: the registry would otherwise fill `{case}` with + * nothing and return `/apps/dossiq/cases/`, a broken link with no brace + * left to catch it. The registry's real substitution runs here. + * + * @dataProvider emptyOwnValues + * + * @param mixed $value The own property's value. + * + * @return void + */ + public function testAnEmptyOwnPropertyFallsBackToTheOpenRegisterRoute(mixed $value): void { + $registration = new DeepLinkRegistration(appId: 'dossiq', registerSlug: 'r', schemaSlug: 's', urlTemplate: '/apps/dossiq/cases/{case}'); + $this->schemaMapper->method('find')->willReturn(new Schema()); + $this->deepLinkRegistry->method('resolveIcon')->willReturn(null); + $this->deepLinkRegistry->method('resolveDisplayName')->willReturn('Dossiq'); + $this->deepLinkRegistry->method('resolveUrl')->willReturnCallback( + static fn (int $registerId, int $schemaId, array $objectData): string => $registration->resolveUrl(objectData: $objectData) + ); + + $entry = $this->formatter->format(['title' => 'Losse notitie', 'case' => $value, '@self' => ['id' => 'u1', 'register' => 1, 'schema' => 2]], null); + + $this->assertSame('/objects/u1', $entry->jsonSerialize()['resourceUrl']); + }//end testAnEmptyOwnPropertyFallsBackToTheOpenRegisterRoute() + + /** + * @return array + */ + public static function emptyOwnValues(): array { + return [ + 'empty string' => [''], + 'blank string' => [' '], + 'false' => [false], + 'true' => [true], + ]; + }//end emptyOwnValues() }//end class 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'); 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 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 diff --git a/tests/Unit/Service/Task/TaskBlockedByCreateTest.php b/tests/Unit/Service/Task/TaskBlockedByCreateTest.php index 707f976355..85ef02f19c 100644 --- a/tests/Unit/Service/Task/TaskBlockedByCreateTest.php +++ b/tests/Unit/Service/Task/TaskBlockedByCreateTest.php @@ -92,13 +92,19 @@ static function (Task $task): Task { /** * The service under test, with real builder and mocked stores. * + * @param bool $administrator Whether the actor is an administrator. + * @param array|null $readable The uuids of the tasks an ordinary actor may read. + * * @return TaskService The service. */ - private function service(): TaskService { + private function service(bool $administrator = true, ?array $readable = null): TaskService { $audits = $this->createMock(TaskAuditMapper::class); $audits->method('insert')->willReturnArgument(0); $authorization = $this->createMock(TaskAuthorizationService::class); - $authorization->method('isAdministrator')->willReturn(true); + $authorization->method('isAdministrator')->willReturn($administrator); + $authorization->method('mayRead')->willReturnCallback( + static fn (Task $task, ?string $uid): bool => in_array($task->getUuid(), ($readable ?? []), true) + ); return new TaskService( tasks: $this->tasks, @@ -207,4 +213,54 @@ public function testALoopOfBlockersIsRefused(): void { $this->service()->create(data: ['uuid' => 'task-a', 'title' => 'A', 'blockedBy' => 'task-b'], actor: 'anna'); }//end testALoopOfBlockersIsRefused() + + /** + * An ordinary caller may not name a blocker they cannot read: it answers like a task that does not exist. + * + * @return void + */ + public function testABlockerTheCallerMayNotReadAnswersLikeOneThatDoesNotExist(): void { + $this->store(uuid: 'task-elsewhere'); + $this->tasks->expects($this->never())->method('insert'); + + $messages = []; + foreach (['task-elsewhere', 'task-ghost'] as $blocker) { + try { + $this->service(administrator: false, readable: [])->create(data: ['title' => 'Write the advice', 'blockedBy' => $blocker], actor: 'anna'); + $this->fail('Expected the blocker to be refused'); + } catch (TaskValidationException $e) { + $messages[$blocker] = str_replace($blocker, '', $e->getMessage()); + } + } + + $this->assertSame($messages['task-ghost'], $messages['task-elsewhere'], 'the two refusals must read the same'); + }//end testABlockerTheCallerMayNotReadAnswersLikeOneThatDoesNotExist() + + /** + * A blocker the ordinary caller may read is accepted as before. + * + * @return void + */ + public function testABlockerTheCallerMayReadIsAccepted(): void { + $this->store(uuid: 'task-visit'); + + $created = $this->service(administrator: false, readable: ['task-visit']) + ->create(data: ['title' => 'Write the advice', 'blockedBy' => 'task-visit'], actor: 'anna'); + + $this->assertSame('task-visit', $created->getBlockedBy()); + }//end testABlockerTheCallerMayReadIsAccepted() + + /** + * The trusted import path names blockers of its own making and is not held to a read rule. + * + * @return void + */ + public function testTheTrustedImportPathIsNotHeldToAReadRule(): void { + $this->store(uuid: 'task-visit'); + + $created = $this->service(administrator: false, readable: []) + ->import(data: ['title' => 'Write the advice', 'blockedBy' => 'task-visit'], actor: 'anna'); + + $this->assertSame('task-visit', $created->getBlockedBy()); + }//end testTheTrustedImportPathIsNotHeldToAReadRule() }//end class 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 { diff --git a/tests/e2e/credential-oauth2-connect.spec.ts b/tests/e2e/credential-oauth2-connect.spec.ts index 84baa627d5..ea3c10e519 100644 --- a/tests/e2e/credential-oauth2-connect.spec.ts +++ b/tests/e2e/credential-oauth2-connect.spec.ts @@ -30,7 +30,7 @@ * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-re-authorisation-overrides-the-same-credential * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-disconnecting-revokes-upstream-where-it-can-and-disables-locally * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-bluesky-is-its-own-client-and-mastodon-registers-per-instance - * @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 */ import { expect, test } from '@playwright/test'