Release: merge development into beta - #71
Conversation
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (110 total)
npm dependencies (266 total)
PHPUnit Tests
Code coverage: 0% (0 / 10 statements) Integration Tests (Newman)Newman integration tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (110 total)
npm dependencies (266 total)
PHPUnit Tests
Code coverage: 0% (0 / 10 statements) Integration Tests (Newman)Newman integration tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
PHPUnit Tests
Integration Tests (Newman)
Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (110 total)
npm dependencies (266 total)
PHPUnit Tests
Integration Tests (Newman)Newman integration tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (110 total)
npm dependencies (266 total)
PHPUnit Tests
Code coverage: 0% (0 / 10 statements) Integration Tests (Newman)Newman integration tests were not enabled for this run. Generated automatically by the Quality workflow.
|
…151) from ci/phpcs-cleanup into development
Add COMPOSER_AUTH env to all composer install steps so downloads go through the authenticated GitHub API instead of anonymous codeload.github.com, stopping the intermittent HTTP 400 throttle failures. Also adds retry logic and COMPOSER_MAX_PARALLEL_HTTP=1 to match the fleet pattern (procest PR #144). Adds secrets:inherit to app-tests.yml so the reusable tests.yml job can read the org-level GH_COMPOSER_TOKEN secret.
…_COMPOSER_TOKEN)' (#152) from ci/composer-auth into development
…dossier scope
Replace the lightly-tested batch-anonymise endpoint with per-file calls to
POST /api/anonymization/anonymize/{fileId}, passing scope:'dossier' and a
dossierKey so placeholder numbering stays consistent across the folder.
Folder analysis still runs through the batch path. The batch CSV report
button is temporarily removed (the batch record is no longer marked
completed) and returns once per-file results are written back.
…low met dossier-scope' (#154) from feature/folder-analyse into test/anonimiseren-bij-de-bron-dd Reviewed-on: https://codeberg.org/Conduction/docudesk/pulls/154 Reviewed-by: Remko Huisman <remko48@noreply.codeberg.org>
…g onto one page
page-break-inside: avoid on the table element forced mPDF to keep an
entire data table on a single page, making large summaries (e.g. the
696-row grondslagen report) unreadable. Apply the avoid rule to table
rows instead so tables flow across pages with rows kept intact, and add
thead { display: table-header-group } so the column header repeats on
every page. Covered by two new PdfService unit tests.
Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Robert Zondervan <robert@conduction.nl>
…erview, and add re-anonymize from the original view
Add a third outputFormat value `pdf-only` that converts the anonymised document to PDF and then best-effort deletes the native-format intermediate, so a re-editable copy of the redacted document is not left behind. Make `pdf-only` the new tenant + service default; `pdf` (keep both) and `preserve` (native only) remain available. Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Robert Zondervan <robert@conduction.nl>
- rename the document edit mode to an explicit add process: the Edit button becomes Add, Cancel leaves the process, and Save change becomes Add entity which closes the panel after adding - show a Material Symbols ink-highlighter marking cursor while adding so it is obvious text can be selected to add an entity - rename store editMode/setEditMode to addMode/setAddMode (and the isEditing/isEditMode computeds) for consistency, with tests updated
Replace the boolean "always export as PDF" toggle with a pdf-only / pdf / preserve radio group so the new pdf-only default is selectable and is no longer silently coerced to preserve when the settings panel is saved. Update the change's spec, tasks, and plan to cover the admin-UI requirement. Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Robert Zondervan <robert@conduction.nl>
Update the eml-pdf-assembly OpenSpec change to consume OpenRegister's redacted AnonymisedEmlStructure (depends on openregister#241) and assemble one PDF/A-3b from already-redacted components: redacted header block (incl. Reply-To) + redacted body + redacted attachment pages. Unsupported attachments become placeholder pages with content dropped; verbatim embedding of originals is removed (would leak PII); EML always outputs PDF (preserve silently overridden). Adds the tracking plan (Codeberg #156). No production code yet. Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Robert Zondervan <robert@conduction.nl>
…' into acato/dashboard
Render the original (un-redacted) content of a message/rfc822 file to a
PDF/A-3b server-side and show it in the in-app file viewer, instead of the
"cannot be previewed" fallback. The preview reuses the eml-pdf-assembly
pipeline with an empty entity set, so there is no second email-render path.
- EmlPreviewService::renderOriginalPreview(fileId) resolves OpenRegister's
FileService, calls anonymizeEmlStructured with an empty entity set, and
assembles via EmlPdfAssemblyService; writes no file.
- EmlPreviewController::preview streams the PDF (NoAdminRequired,
NoCSRFRequired), 422 with a PII-free error on failure.
- Route eml_preview#preview -> GET api/anonymization/eml-preview/{fileId}.
- Viewer: PdfViewer gains an optional url prop; FileViewerPage maps
message/rfc822 / .eml to an eml kind rendered through PdfViewer via the
preview endpoint. Upload widget now accepts .eml / message/rfc822.
Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the eml-pdf-assembly change: EML (message/rfc822) inputs are anonymised by OpenRegister and assembled into a PDF/A-3b by DocuDesk, which performs no redaction itself and embeds no original/redacted bytes as file attachments. - EmlPdfAssemblyService::assemble() renders OR's redacted structure — envelope (NL labels), body, inline cid: images from OR's map, per-attachment pages (renderable via the cascade, else placeholder), nested EML up to depth 3 — as a multi-pass PDF/A-3b. Twig templates under lib/Resources/templates/eml/. - AnonymizationService routes EML inputs to a dedicated anonymizeEmlToPdf() branch (OR's anonymizeDocument() throws on message/rfc822); preserve is silently overridden to PDF. No raw-parse fallback: OR API failure surfaces as ConversionFailedException (HTTP 422), never un-redacted content. - EmlBackend un-stubbed: isAvailable() reflects both deps; convert() delegates to the assembly service. - PdfService exposes createMpdfInstance()/applyPrintCss() so multi-pass assembly reuses the single-pass PDF/A-3b settings. - EntityDetectionService: keep digits-only values classified as PII (BSN, phone, IBAN) and only drop generic numeric noise (NUMBER/CARDINAL/…). - Bump mpdf to ^8.3. Assisted-by: ClaudeCode:claude-opus-4-8 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DocuDesk should hold no opinion on whether a value is redactable based on it being a number. Numbers are frequently sensitive — a BSN, a phone number, a granted-benefit amount — so the redaction decision belongs to OpenRegister, which redacts every entity it is handed. Remove the numeric filter (and the GENERIC_NUMERIC_TYPES list) from mapEntitiesForAnonymization; only empty and too-short values are still dropped. Update the tests to assert numeric values are kept. Assisted-by: ClaudeCode:claude-opus-4-8 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The l10n tooling (l10n-ai.js / clean-l10n.js) maintains only the frontend l10n/*.js files by design; the backend l10n/*.json files read by PHP IL10N were never regenerated and had drifted ~198 (nl) / ~278 (en) keys behind, so the backend fell back to English for every string added since. Add a serializeJson emitter to scripts/lib/l10n.js plus scripts/sync-l10n-json.js to derive the .json twins from the .js source of truth, and regenerate en.json / nl.json to key-parity (also adding the previously-absent pluralForm). Assisted-by: ClaudeCode:claude-opus-4-8 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…indings (#368) Re-measured on the GitHub base (which is 54 commits ahead of Codeberg's stale mirror), so this ports the fixes rather than the diff of Codeberg #191. Most of that PR's debt is already gone here: psalm and phpstan are clean on this base, and composer.json's check:strict scripts have already been un-masked. What remained: phpcs — a single ERROR, Squiz.Commenting.InlineComment.NotCapital in CustomDictionaryService. The other 183 findings are @spec-tag WARNINGS and do not fail the run. phpmd — six class-level findings, each suppressed with the reason recorded next to the suppression rather than a bare annotation: CustomDictionaryController ExcessiveClassComplexity (69 vs 50) PortalSigningReceiverController ExcessiveClassComplexity (51 vs 50) AnonymizationService TooManyMethods (29 vs 25) CustomDictionaryService ExcessiveClassComplexity (78 vs 50) SettingsService CouplingBetweenObjects (13 vs 13) SigningService ExcessiveClassLength (1214 vs 1000) The figures are this base's, not Codeberg's — CustomDictionaryService reads 78 here against 77 there, which is why the diff was not cherry-picked. SigningService's length is flagged in the docblock as a genuine split candidate (provider-dispatch and verification halves). That is a behavioural refactor and is deliberately not folded into a quality-gate change. Positive control: removing the ExcessiveClassLength suppression makes phpmd exit 2 naming lib/Service/SigningService.php and the rule; restoring it returns phpmd to 0. The suppressions are load-bearing, not decorative. composer check:strict exits 0 on PHP 8.3 with a fresh composer install (1118 tests, 3425 assertions).
…Unit (#366) * refactor(quality): split SettingsService and CustomDictionaryService Extract OpenRegisterAvailabilityService from SettingsService (CouplingBetweenObjects 13 -> under threshold) and split CustomDictionaryService into a repository, a fail-closed organisation access gate and a payload normaliser (ExcessiveClassComplexity 78 -> under threshold). * refactor(quality): collapse CustomDictionaryController's repeated guard/catch scaffolding Nine endpoints each repeated the same auth + availability guard and the same exception ladder. Both now live in a single dispatch()/guard() pair, so each endpoint is one expression (ExcessiveClassComplexity 69 -> under threshold). * refactor(quality): extract PortalSigningDocumentResolver from the signing receiver viewDocument's file lookup carried no authorisation logic but pulled IRootFolder and File into the trust-boundary controller. Moving it verbatim into a collaborator clears both ExcessiveClassComplexity (51) and CouplingBetweenObjects (13); the fail-closed ordering, its call site and all 15 receiver tests are unchanged. * refactor(quality): extract CustomDictionaryDetectionRunner from AnonymizationService The custom-dictionary detection pass was five cohesive private methods on an already-oversized service (TooManyMethods 29). Moved verbatim into its own runner; AnonymizationService now delegates one call. * refactor(quality): split SigningService below the class-length threshold Extract SignedArtifactProducer (produce + store the verifiable signed artifact, resolve the document node and signer label) and SigningRequestValidator (request data + provider/level honesty at creation). Both moved verbatim; SigningService keeps the lifecycle orchestration and the conclusion emitter. Applied phpcbf to the touched files. phpmd is now 0 findings (exit 0) for docudesk.
docudesk's CI was fully green — 24 jobs pass, 0 fail — and the green said
nothing about the code. `phpmd.baseline.xml` held 196 entries and the
baseline-less count was 244, so `PHP Quality (phpmd)` was measuring the
baseline, not `lib/`.
before: 244 findings, exit 2 without the baseline / exit 0 with it
after: 0 findings, exit 0 with NO baseline file present
The baseline file and the `--baseline-file` flag in composer.json are both
gone. They have to go together: with the flag left behind, phpmd exits 1
with a one-line "Unable to determine the realpath" and no findings output,
which reads like a broken tool rather than a gate.
Nothing was suppressed, no threshold was changed, and no rule was removed.
`phpmd.xml` is untouched.
## How the 244 were cleared
Real decomposition, not annotation. ~60 new collaborator classes across
Charts, Conversion, EML, Grondslagen, Anonymization, Consent/Policy and
Signing; the fat classes delegate to them and keep their public surface.
The largest: ChartSvgRenderer 1344 -> 329 lines behind 11 collaborators,
AnonymizationService 2248 -> 999 lines, GrondslagenSummaryService and
EmlPdfAssemblyService each split into 5-6 focused classes.
Behaviour-preservation was measured, not asserted. The chart/table/diff/
validation rewrite was differential-tested against the HEAD implementations
loaded side by side under a second namespace: 21,880 cases, 0 mismatches,
comparing json_encode output so array key order is covered too, each
harness carrying a positive control proving it can detect a difference.
## Suppressions: 75 -> 17, and the remaining 17 are honest
30 dead annotations removed in the first pass, 28 more here. The set-diff
on removal caught three I had classified wrongly: `filterSchemas`,
`filterSchemaProperties` and `replaceConditionalSection` are reached as
`[$this, 'method']` callable arrays, which PHPMD's UnusedPrivateMethod
cannot see. Those three suppressions were correct all along and are
restored — now with a stated reason, which they never had.
Worth recording: `phpmd --strict` (which reports findings even where a
suppression applies) does NOT surface UnusedPrivateMethod, so a
strict-vs-normal diff has a blind spot for that rule. Only the
remove-and-set-diff caught it.
One genuine two-docblock trap found: PortalSigningReceiverController's
`@SuppressWarnings` sat in the FILE docblock, above `declare(strict_types=1)`,
where PHPMD never applies it. It had been silently doing nothing.
## Cross-app change
`DocumentSigningRequestedEvent`'s 11-parameter constructor now takes the
`SigningProvenance` value object its sibling `SigningConcludedEvent`
already took — 11 params -> 6, and the two ends of the exchange finally
carry provenance the same way. The read surface is unchanged.
shillinq is the only external app that CONSTRUCTS this event and must land
its matching change (ConductionNL/shillinq#423) together with this one.
Verified against the real classes: the new shape constructs and all ten
accessors return the same values; the old shape raises
`Error: Unknown named parameter $sourceApp`.
## Security coverage gap found and closed
`SigningService::getRequest()`'s single-record caller scoping — the WF2 /
anti-existence-probing contract — was pinned by NOTHING. Disabling the
guard entirely left the whole suite green. Four tests were added (one
negative control verified to fail with the guard disabled, three positive)
before the surrounding refactor touched it.
Also noted, not fixed: `Wave12SecurityRegressionTest::testSigningServiceList
RequestsFiltersForNonAdmin` is green-but-dead — it re-implements the filter
in a local closure and asserts against its own copy, never calling
SigningService. Its own comment admits it. Real coverage is in
SigningServiceTest; that one should not be read as evidence.
## Verification
- `composer phpmd` exit 0 with no baseline file present.
- Negative control: an injected `else` expression -> exit 2 naming the file
and rule; removing it -> exit 0.
- PHPCS 0 errors (41 regressions introduced by the refactor, all fixed).
- PHPStan clean, Psalm clean (5 regressions introduced by the refactor,
all fixed — including a genuine `getTemporaryFile(): string|false`
return that was being handed straight to `file_put_contents()`).
- PHPUnit 1127 tests / 3453 assertions / 2 pre-existing environmental skips.
## Route names changed, URLs did not
PolicyController and TemplatesController were split (StandingConsentController,
TemplateVersionsController, TemplatePreviewController). Every URL, HTTP verb
and auth attribute is byte-identical; only internal route names moved. The
frontend addresses these by literal path via `generateUrl('/apps/docudesk/...')`,
never by route name — checked. Stale prose naming the old methods is updated
in src/manifest.json, the entity-publication-policies spec, and the
policy-controller-test-coverage change.
… route (#369) Nextcloud's router `ReflectionClass()`es every file in `lib/Controller/` while MATCHING a route, and `include`s `appinfo/routes.php` on every request. DocuDesk bound OpenRegister classes in both positions, so with openregister absent EVERY docudesk route returned HTTP 500 — not just the AppHost ones. DocuDesk does not declare `<app>openregister</app>`, so an admin can create exactly that state. Lazy DI cannot fix this: `extends` is resolved by the autoloader, not the container. Adopt the AppHost by composition instead of inheritance (decidesk#388): - Dashboard/Health/Metrics/Preferences controllers extend OCP's Controller and implement their methods locally. Every attribute previously INHERITED (#[NoAdminRequired], #[NoCSRFRequired], #[PublicPage]) is now declared explicitly, so the auth posture is unchanged. - Health/Metrics resolve the engine from the container by FQCN string and degrade instead of 500ing: health answers `status: degraded`, `checks: {openregister: unavailable}` at HTTP 200; metrics answers 503 with a Prometheus comment line. Preferences needs nothing but OCP. - `appinfo/routes.php` calls the AppHost builder behind `class_exists()` with a local fallback table. - Application.php drops the dead leaf-AppHost aliases and registers the MetricsEngine under its FQCN string, preserving the explicit construction. Reflection probe mirroring Router::getAttributeRoutes(), openregister absent: 29 controllers scanned, 25 ok / 4 fail before -> 29 ok / 0 fail after; routes.php fatal before -> 103 routes after. Fallback table proven `===` identical to Routes::standard() (103 routes, same order), with a one-field mutation correctly detected as different. lint/phpcs/phpmd/psalm/phpstan clean; phpunit 1123 tests, 3432 assertions.
Both sides of this conflict had real work and both are kept. development carries #369, which stopped a 500 on EVERY docudesk route when openregister is absent: it deleted the container aliases binding leaf AppHost class names to the OpenRegister generics, turned Dashboard/Preferences/Health/ Metrics into local OCP-only controllers, and guarded appinfo/routes.php with class_exists() plus a local fallback route table. This branch carries the phpmd work, which decomposed Application.php (334 lines) into seven registrars. Taking either side wholesale was wrong. The branch's AppHostControllerRegistrar and ObservabilityRegistrar are verbatim extractions of the code #369 deleted — they re-register the aliases, re-subclass the generics, and reference OpenRegister classes from top-level `use` imports and closure RETURN TYPES, which is exactly what #369's comment forbids. Merging them as-is would have reverted the fix while the diff read as a pure refactor. So the decomposition is rebuilt on #369's design: - AppHostControllerRegistrar deleted. Under #369 there are no aliases at all; the controllers auto-wire and the routes resolve to real local classes. - ObservabilityRegistrar reduced to registering ONLY the MetricsEngine, under its FQCN string, with an `object` return type and no OpenRegister imports. - MetricsEngineFactory::build() returns `object`; every OpenRegister name is now a string resolved inside the method body, so nothing in the file is resolved at class-declaration time. - RegistrationBootstrap::register() drops the now-unused $appName parameter and documents the deliberate absence of a controller registrar. appinfo/routes.php auto-merged correctly and was verified to retain both #369's class_exists() guard + fallback table and this branch's controller-split route renames (standingConsent#, templatePreview#, templateVersions#). Verified on the merge result: no `use OCA\OpenRegister` and no OpenRegister parent in lib/Controller/ or lib/AppInfo/ outside two `::class` event-name constants that do not autoload; php -l clean; phpcs 0 errors on lib/AppInfo/; phpmd 0 violations on lib/AppInfo/.
fix(quality): clear all 244 phpmd findings and delete the baseline
Codeberg is retired; ConductionNL is GitHub-only. GitHub Actions never executes .forgejo/**, so these 8 workflows contributed zero status checks. Every one has a GitHub counterpart: code-quality.yml (shared quality pipeline), l10n.yml, documentation.yml, release-beta.yml, release-stable.yml. Also repointed the .forgejo reference in tests/e2e/visual/README.md.
…all/psalm/phpstan (#374) The guard made the scripts always exit 0. Measured: 1123 tests now run and the suite fails when an assertion is broken.
chore(deps): @conduction/nextcloud-vue 3.0.0-vue3.6
…pping (#377) A skipped job and a passing job are indistinguishable in the Quality Report. Every gate turned on here reported 'skipped' in every run. Each newly-enabled leg was measured against this tree BEFORE being enabled; the results are in the PR description. Legs that were measured failing are enabled anyway - the defects are pre-existing, and the only thing that changed is that CI can now see them. Journeydoc Capture and enable-axe are deliberately NOT enabled.
…opment (#379) The initial 52.88 was read from the most recent coverage-report artifact, which turned out to be a pull_request run of a DIFFERENT branch: 10961 statements against development's 16778. Recency is not provenance. 61.39 (10300/16778) comes from run 30925515438 - push, development, sha 2a660aa - verified by the artifact's workflow_run.head_branch and head_sha rather than by being newest. The gate caught this on its first ever execution.
The 3.0.0-vue3.* line is being withdrawn from npm. The major was cut from a real BREAKING CHANGE footer (the retired action-list flow editors), but it applied to a prerelease channel only our own apps consume, so the line is resumed at 2.x rather than carried forward. 2.2.0-vue3.1 is a superset of 3.0.0-vue3.6 — it additionally carries the recovered Vue 2 -> Vue 3 component conversion and the CnGraphCanvas port/loop work. Despite the lower version number this is not a downgrade in content. The lockfile is regenerated with npm 10.8.2, matching CI's node 20 toolchain. Local npm 11 prunes optional entries that do not apply to the current platform, which makes CI's `npm ci` fail with "Missing: ... from lock file"; running `npm ci` locally does not reproduce it because npm 11 accepts its own lockfile. Verified: `npx npm@10.8.2 ci --dry-run` exits 0, and `USE_LOCAL_LIB=false npm run build` exits 0 with no unresolved modules. The USE_LOCAL_LIB=false is load-bearing — this repo's webpack aliases @conduction/nextcloud-vue to a sibling ../nextcloud-vue/src checkout when one exists, so a plain build can silently compile the sibling's source instead of the package under test.
…d routes (#382) * fix(security): authorise the relation skip-decision, and revive 5 dead routes Three defects found by re-running the Hydra gates against the FULL tree (the enablement PR only ever scoped them to its own diff, so every one of these gates passed in ~1.5s having read no lib/ and no routes.php). 1. IDOR on PATCH /api/anonymization/relations/{id} (gate-7). `AnonymizationController::updateRelation()` forwards a caller-supplied primary key through four services to `EntityRelationMapper::find($relationId)` — an UNSCOPED primary-key lookup — and then writes. Nothing in that chain checked who was asking. Any authenticated user could flip `skipAnonymization` (and `bases`) on a relation belonging to someone else's document, i.e. cause a third party's PII to be left un-redacted in an anonymised/published document. The prohibition policy that guards this path is a CONTENT policy, not an authorisation check — it constrains WHICH entities may be skipped, never WHOSE. The relation carries the Nextcloud file id of the document it was detected in, so authorisation is now decided the same way `AnonymizeRequestService::verifyFileAccess()` already decides it for extract/anonymize: resolution through the acting user's OWN file tree (`getUserFolder($uid)->getById()`), never `IRootFolder::getById()`, which searches every storage. An unreachable relation returns the SAME 404 as a non-existent one, so the endpoint is not an existence oracle. `updateDecisionMetadata()` is now also passed the acting user, so OpenRegister's audit trail records who flipped the decision — it takes an `?IUser $actingUser = null` and we were passing nothing. 2. Three dead portal-signing routes (gate-5 + gate-14, in agreement). `portalsigningreceiver#…` — Nextcloud builds the controller class with `ucfirst()` only, so it resolved to `PortalsigningreceiverController`, which does not exist. view / sign / decline for external portal signers were unreachable. Renamed to `portalSigningReceiver#…`, matching the class. This is exactly the trap the comment 30 lines above it warns about. 3. Two canonical settings routes with no method (gate-5 + gate-14). `Routes::standard()` ships `settings#update` (PUT) and `settings#load` (POST) for EVERY app; DocuDesk's SettingsController implemented only index + create, so both routes blew up on dispatch. Added `update()` (same admin-guarded write path as `create()`, per the canonical GenericSettingsController) and `load()` (re-runs register initialisation). Verification - Two new tests assert the denial paths, and were confirmed to FAIL with the guard neutered — the failure output shows `updateDecisionMetadata()` being called with a foreign relation and a null actor, i.e. the pre-fix behaviour reproduced. - Full unit suite: 1129 tests green. - Hydra gates, full tree: 18 -> 16 failing. gate-5 5->PASS, gate-14 5->PASS, gate-7 2->1. The one remaining gate-7 finding (`EmlPreviewController::preview`) is reported separately: it streams an un-redacted preview of an arbitrary fileId through OpenRegister's `FileService::getFileById()`, whose ownership check is `$node->isReadable()` on a ROOT-view node. Whether that is user-scoped needs a live check before anything is changed, so it is not touched here. * fix(quality): inject the skip-decision service, and cover the two new settings methods Two CI failures from the previous commit, both mine, both fixed at the cause. 1. phpmd CouplingBetweenObjects 14 > 13 on ProhibitionPolicyService. The authorisation guard needs IUserSession + IRootFolder, and RelationSkipDecisionService was being `new`ed inside ProhibitionPolicyService — so every dependency it needs had to be threaded through a class that never uses them. Injecting the collaborator instead puts that coupling where it belongs and takes ProhibitionPolicyService back under the limit. Better wiring than what it replaces, not a workaround: the container now builds RelationSkipDecisionService, and the policy service drops two constructor params it only forwarded. 2. Coverage dropped 0.09%. SettingsControllerTest was four `assertStringContainsString('function index()', $content)` assertions — it executed no controller code, so the new update()/load() methods landed uncovered. Replaced with behavioural tests that construct the controller and call both: the admin write path, the admin initialise path, the non-admin 403 and the no-session 401, each asserting that SettingsService is NOT touched on the denial branches. The baseline was NOT lowered. Suite: 1133 tests green. phpmd exit 0, phpstan OK, phpcs 0 errors. * style(phpcs): drop the stray blank line in the now-empty constructor body * style(phpcs): drop the blank line after the class brace The blank line was legal while a property declaration sat between the brace and the constructor. Injecting RelationSkipDecisionService removed that property, so the docblock became the first member and Squiz.WhiteSpace.FunctionSpacing wanted 0 blank lines before it. Verified by exit code (`phpcs; echo $?` = 0), not by grepping the report — an earlier `grep -cE 'ERROR\b'` returned 0 against ANSI-coloured output and read as clean when it was not.
* chore(deps): move to @conduction/nextcloud-vue 2.2.0-vue3.3
Picks up the two releases that landed after 2.2.0-vue3.1:
2.2.0-vue3.2 four dashboard defects — date-range chip shows its dates and
calendar-aligned presets, a dangling labelResolve no longer
renders a raw UUID, and the table's "View all" pins to the
bottom instead of scrolling away
2.2.0-vue3.3 gridstack's stylesheet now ships with the library that
requires it; CnFormDialog splits over-long schema descriptions
behind an info popover; CnContextMenu closes again on outside
press and stops hijacking every popper with a cursor transform
Lockfile regenerated with npm 10.8.2 to match CI's node 20 toolchain — local
npm 11 prunes optional entries that do not apply to the current platform, which
makes CI's `npm ci` fail with "Missing: ... from lock file". Running `npm ci`
locally does not reproduce it, because npm 11 accepts its own lockfile.
Verified: `npx npm@10.8.2 ci --dry-run` exits 0, and `USE_LOCAL_LIB=false
npm run build` exits 0 with no unresolved modules and no reference to a sibling
nextcloud-vue checkout. USE_LOCAL_LIB=false is load-bearing: webpack aliases
@conduction/nextcloud-vue to ../nextcloud-vue/src when that sibling exists, so
a plain build can silently compile the sibling instead of the package under test.
* fix(deps): restore the optional lockfile entries npm 11 pruned
The verification build ran `npm install` under local npm 11 AFTER the lockfile
had been regenerated with npm 10.8.2, which silently re-pruned the optional
entries that do not apply to this platform — per-arch esbuild/rolldown/
lightningcss binaries and @nextcloud/vue's optional pinia and vite.
CI runs npm 10.8.2, whose `npm ci` requires those entries, so it failed at
Install dependencies and took every dependent job with it.
Regenerated with `npx npm@10.8.2 install --package-lock-only` and this time
nothing runs npm 11 against it afterwards. Verified with
`npx npm@10.8.2 ci --dry-run` (exit 0).
…failing CI (#387) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.github#147), gate-33 had no axe report to read and never said so (#148), and gates 6 and 7 reported PASS on an empty scope (#149). The tick was identical either way, which is why nothing in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
The app is licensed EUPL-1.2 — composer.json, package.json, package-lock.json, appinfo/info.xml and the bundled LICENSE all say so — but six test/config files still carried AGPL-3.0-or-later SPDX headers left over from the Nextcloud app template. A licence header is a legal claim, so a repo that states two different licences about itself is a real defect, not a lint nit. SPDX-License-Identifier: AGPL-3.0-or-later -> EUPL-1.2 on: - playwright.config.ts - tests/e2e/base-url.ts - tests/e2e/docs-screenshots.spec.ts - tests/e2e/global-setup.ts - tests/e2e/visual/_visual-helpers.ts - tests/e2e/visual/docudesk.visual.spec.ts No @copyright / SPDX-FileCopyrightText line was touched — this normalises the licence only. Every lib/**/*.php file already declared EUPL-1.2, and no third-party or vendored file in this repo declares a non-EUPL licence, so nothing was left behind. Header-only change. PHPUnit 1133 tests / 3470 assertions, 2 deprecations, 1 skipped — identical before and after (PHP 8.4). vitest 45 passed before and after. hydra gate-28 license-triangle PASS before and after.
Follow-up to #388. The OpenAPI document's info.license.name said 'agpl' while composer.json, package.json, appinfo/info.xml, the bundled LICENSE and every source header say EUPL-1.2. Every generated client, API portal and spec viewer reads info.license.name, so this is a licence claim shipped to consumers. My sweep in #388 missed it because I grepped for SPDX identifiers and @license tags — this one is a lowercase JSON value in a different shape entirely.
…o its own ruleset (#384) * fix(phpmd): scope the lib/Migration UnusedFormalParameter exclusion to its own ruleset The nested <exclude-pattern> inside the UnusedFormalParameter <rule> was inert: PHPMD 2.15 honours exclude-patterns only as direct children of <ruleset>, so lib/Migration was scanned by the very rule the pattern was written to spare. Hoisting the pattern to the top level of phpmd.xml would have worked but is applied at file-collection time, dropping lib/Migration from EVERY rule and silently swallowing real complexity, StaticAccess and method-length findings. UnusedFormalParameter now lives alone in phpmd-unusedparams.xml with a top-level */Migration/* exclude, and the phpmd composer script runs both legs keeping the worst exit code. * fix(phpmd): narrow the exclude-pattern to */lib/Migration/* */Migration/* matches any directory segment named Migration, so it would also exempt ordinary classes under lib/Service/Migration/ and similar, which have no interface-mandated signature and must stay analysed. Measured on openconnector, that broader form hides a genuine UnusedFormalParameter finding in lib/Service/Migration/. Only the app's own lib/Migration/ holds IMigrationStep implementations, so only that directory is exempted. Re-verified after the change: the lib/Migration probe is still excluded, the non-UnusedFormalParameter Migration probe is still reported by leg 1, and the retired-finding counts are unchanged.
…ne (#392) The standing 'Release: merge development into beta' PR has head_ref 'development', so its pull_request run rendered the same concurrency group as a push to development. cancel-in-progress killed the push run, which is the only carrier of the push-only jobs (Coverage Baseline Check, SBOM, Features Extract). Those jobs report 'skipped' on the surviving PR run, which renders like a pass, so the gate never produced a verdict. Suffixes -push on the group for main/development pushes only; feature-branch dedup is unchanged. No gate weakened: no waiver, baseline, threshold or continue-on-error. Same fix as openconnector#1158.
…kflow needs The Hydra Gates job fails with a message that says outright it is not about this repository: hydra-gates-ref <old> does not contain: scripts/lib/check_spec_anchors.py scripts/lib/check_form_labels.py scripts/lib/check_license_triangle.py The reusable workflow floats on @main and calls those scripts BY PATH inside the PINNED package, so a pin older than the scripts cannot run the gates that implement them. A pinned ref is a silent expiry date on every upstream change, and the failure reports on the pin while saying nothing about the code. v1.5.0 is the first tag containing all of them, verified by reading each path at that tag rather than assuming the newest tag has everything. Swept across the fleet: 11 of 13 repos were pinned below v1.5.0 and every one of them was failing this way.
…#394) .coverage-baseline was read as a floor by the phpunit guard and as an exact target by the push-side staleness check. Together they demand equality with a checked-in constant, which against a moving base branch is not satisfiable: closing "stale" means committing the value the tree will measure after the PR lands. Measured on openregister — committed 58.93, development advanced 16030->16038 tests, merge result measured 58.88, guard reported a 0.05% drop. coverage-guard.php gains --against=<clover.xml>, naming a report measured at the merge base. When present it is the only floor; the committed constant is reported but not enforced. Both numbers then come from one driver in one job, so the xdebug/pcov statement-counting difference cancels rather than being baked in, and the merge base cannot go stale. Ratios are compared as exact integer cross-products, not rounded percentages: at two decimals a one-statement regression read as "unchanged" and exited 0. An empty or zero-statement report is now a hard error rather than 0%, which as the merge-base side would set the floor to zero and pass every drop. Verified on real CI clover artifacts: a genuine 1.44% drop fails, an unchanged tree passes, and adding untested code fails while adding tested code passes.
* chore(ci): move hydra-gates-ref v1.3.0 -> v1.4.0 A pinned `hydra-gates-ref` is a silent expiry date on every upstream fix: this repo cannot receive a gate-package change until this line moves. v1.4.0 is the latest tag and the first one that carries `hydra-gates/scripts/axe-run.cjs` (verified absent at v1.3.0), so it is also the first that has ConductionNL/.github#168 axe DOM scoping and ConductionNL/.github#165 gate-46 fix. `enable-axe` is deliberately NOT enabled in this commit. Ordering matters: the ref lands first, enabling axe is a separate decision. * chore(ci): stop pinning hydra-gates — track the package at @main Removes the `hydra-gates-ref` input from the `quality.yml` caller. The shared workflow already defaults it to `main`, and this repo consumes `quality.yml` itself at `@main`, so dropping the override makes both sides move together: a gate-package fix lands here without a commit here. A pin is a silent expiry date on every upstream fix, and we have paid for that twice already: - .github#159 — 22 repos sat on v1.0.1, which predated the gate fixes. 16 gates were dead fleet-wide and every single one reported PASS. A gate that never runs emits a tick identical to one that did, so nothing in any repo's history showed it. - .github#173 — the shared side flipped a default at @main while the package stayed pinned per caller. Old runners lacked the coverage accounting the new default assumed, so they went red on gates they had no subject matter for. Removing the pin closes both shapes at once. Rolling back is a revert on ConductionNL/.github main, which reaches the whole fleet in one commit; holding this one repo still is still possible by setting the input explicitly, with a reason. `enable-hydra-gates: true` is unchanged. `enable-axe` remains unset. The comment block that justified the pin is replaced with a short note saying why there is no pin. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…ommand injection) (#397) quality / Security (composer) is red on every PR here as of today: Advisory ID: PKSA-rdkp-vv9z-mjkg CVE: CVE-2026-67434 — OS Command injection Affected versions: <3.13.6|>=4.0.0,<4.0.2 Reported at: 2026-08-05T23:53:11+00:00 The advisory was published YESTERDAY and roave/security-advisories installs as dev-latest each run, so the same lockfile was clean on 2026-08-05 and is vulnerable on 2026-08-06 with no commit in between. The last green run is evidence of when it ran, not that the lockfile is safe. composer.json's existing constraint already permits the fixed version, so this is a lockfile move only: 1 update, 0 installs, 0 removals. Verified the diff touches exactly two lines, both the version string, and no other file. Part of a fleet sweep — 13 of 16 repos checked were on the affected 3.13.5.
…398) Measured on `development` (npm audit --package-lock-only), not the Dependabot alert list, which is computed against the stale default branch `main`. Before: 2 critical, 7 high, 11 moderate, 5 low (25) After: 0 critical, 0 high, 10 moderate, 5 low (15) The overrides were CAUSING most of the highs This repo pinned two blanket overrides: "brace-expansion": "1.1.14" "minimatch": "3.1.5" brace-expansion 1.1.14 is covered by three DoS advisories (fixed in 1.1.18). Because the pin was blanket, EVERY consumer was dragged onto it — which is why all of jest, all of eslint, glob and test-exclude reported high. The pins were added to silence old advisories and had become the reason for the new ones. Five further scoped overrides (js-beautify, webdav, typescript-estree, editorconfig, filelist) exist only to escape that blanket pin. Removing the blanket pins alone took 7 high -> 2 high. Root minimatch 3.x cannot leave the tree: eslint 8.57.1, @eslint/eslintrc, eslint-plugin-import, eslint-plugin-n and jest's test-exclude@6 all genuinely require ^3.x, and moving them means eslint 9 + a jest major — far beyond a security sweep. So instead of a blanket pin this uses npm's version-selector form: "brace-expansion@1": "^1.1.18" which lifts ONLY the 1.x line to the patched release that minimatch@3's `^1.1.7` still accepts, while 2.x/5.x consumers keep their own versions (confirmed in the lockfile: root 1.1.18, typescript-estree 2.1.4, coverage-v8 5.0.9). Also - vitest 1.6.1 -> 3.2.7 and @vitest/coverage-v8 to match: clears the critical (Vitest UI arbitrary file read/exec, <=3.2.5). - Dropped the `minimatch: 3.1.5` DIRECT devDependency. Nothing imports it; it existed purely to pin a resolution, and npm still installs it transitively. - `vite: ^7.3.6` override. The vitest bump did NOT move root vite on its own — it stayed deduped at 5.4.21 behind unplugin@3.3.0 (via @nextcloud/vue -> vue-router). Verify vite actually moved rather than assuming the vitest bump drags it. NOT changed, deliberately - node-polyfill-webpack-plugin stays at exactly 4.0.0. A bump to ^4.1.0 fails install: @nextcloud/webpack-vue-config@7.0.2 declares it as a peer pinned to EXACTLY 4.0.0. Its 5 low advisories are accepted for now. Verified (CI is node 20 / npm 10.8.2; local npm 11 prunes entries CI needs, so every lockfile operation used npx npm@10.8.2) - npx npm@10.8.2 ci: rc=0, lockfile md5 identical afterwards. - vitest 45/45 pass (7 files); jest 77/77 pass (7 suites). - test:coverage rc=0; coverage ratchet rc=0 and went UP, 4.24% -> 4.38%. - npm run build rc=0; lint rc=0; check:manifest rc=0; test:l10n rc=0. - Baselines for all of the above were taken on untouched development first, so none of these are pre-existing reds being papered over. - Licences read from the LOCKFILE: vitest, coverage-v8, vite, esbuild and brace-expansion all MIT. vue3-apexcharts is 1.8.0 MIT — still the pre-proprietary line, and untouched by this change.
…ugh (#395) 19 of 66 assertions in tests/integration/docudesk.postman_collection.json failed on every run since `enable-newman` was switched on. Measured baseline, run 31074205317 / job 92528887929 on `development`: 1 collection, 33 requests, 66 assertions, 47 passed, 19 failed. ONE root cause, three response shapes. DocuDesk resolves every template, consent and signing write through app-config bindings `<schemaSlug>_register` / `<schemaSlug>_schema`, which an ADMINISTRATOR sets in the admin settings UI. Nothing auto-provisions them on a fresh install except `templateVersion_*` (SettingsInitializer::provisionTemplateVersionConfig). With them empty: * OpenRegisterResolver::getRegisterAndSchema() throws RegisterNotConfiguredException, whose code is 0, so TemplateRequestHandler::buildErrorResponse() maps it to HTTP 500 -> failures 01-03, 08-12, 19 (templates) ; * SigningService passes an empty register/schema straight to ObjectService -> HTTP 500 -> failures 04-07, 13-15, 18 ; * ConsentController::notConfiguredResponse() answers HTTP 400 -> failures 16-17. `tests/e2e/ci-seed.sh` already performs exactly this provisioning for the Playwright job, and its header already names this failure mode verbatim ("answers 500 `Template register/schema not configured`"). It was simply never wired to the Newman job: `newman-seed-command` was never set. Reusing it verbatim would have made things WORSE, not better. Its closing step is a hard gate that the DocuDesk frontend bundle serves as JavaScript, and the shared workflow's `newman` job has no frontend build step at all — so the seed would `exit 1` and fail the job outright. The script therefore takes an optional mode argument: `e2e` (default, unchanged) keeps the gate; `api` skips the SPA warm-up and gate only. No provisioning differs, and no Newman assertion loads a page. No test was skipped, disabled, relaxed or deleted. Measured on a disposable nextcloud:32-apache + postgres replica of the CI job (worktree bind-mounted, openregister@development alongside): before seeding : 1 / 33 / 66 / 47 passed / 19 failed (identical failure numbers 01-19 to the CI baseline) after seeding : 1 / 33 / 66 / 66 passed / 0 failed Failure proofs — each mutation reverted afterwards, green re-confirmed: * occ config:app:set docudesk template_register --value='' -> 9 failed: 01,02,03,08,09,10,11,12,19 * occ config:app:set docudesk signingRequest_register --value='' -> 8 failed: 04,05,06,07,13,14,15,18 * occ config:app:set docudesk publicationConsent_register --value='' -> 2 failed: 16,17 9 + 8 + 2 = the 19 originally failing assertions, disjointly. * ci-seed.sh with CI=true and NO mode argument -> exit 1 at the bundle gate (proving the `api` flag is load-bearing, and that wiring the seed without it would have hard-failed the job). * ci-seed.sh api with CI=true -> exit 0. * ci-seed.sh bogus -> exit 1, "unknown seed mode".
…e suite can fail (#401) * test(e2e): un-dead two permanently-skipping policy tests; arm a sentinel Run 31167880581 read `76 passed / 6 skipped`. Two of those six were not environment-conditional at all — they could never run, on any environment: page.getByRole('button', { name: 'Add', exact: true }) if (!(await addButton.isVisible().catch(() => false))) { test.skip(true, 'index Add action not rendered on this environment') CnIndexPage renders the primary CTA via CnActionsBar with `:add-label="resolvedAddLabel"`, and in the shipped dist of the pinned @conduction/nextcloud-vue 2.2.0-vue3.3 that resolves to: if (this.addLabel) return this.addLabel return 'Add ' + (this.effectiveSchema?.title || 'Item') Neither ProhibitionIndex.vue nor StandingConsentIndex.vue passes `add-label`, so the accessible name is always "Add <Something>" and never the bare, `exact: true` string "Add". The probe resolved false every run, the self-skip fired every run, and the printed reason ("not rendered on this environment") was untrue. In a summary line a self-skipping spec is indistinguishable from a passing one — these two sat in the "6 skipped" count and read as healthy. Both components declare `:show-add="true"`, so the CTA is REQUIRED to render; its absence is a defect, not an environment condition. Target the stable `data-testid="cn-cta-primary"` CnActionsBar puts on the button (with an `/^Add\b/` name fallback for older shells) and assert it hard. The tests now actually execute the journey they claim to cover. Also adds tests/e2e/spec-coverage/_sentinel.spec.ts — a POSITIVE CONTROL that deliberately fails, so this run must be RED. A green suite is evidence about the reporter until it has been shown it can report a failure. It is removed in the next commit. * test(e2e): remove the sentinel; pre-empt the support-dialog race Run 31172517608, job 92848991910 (E2E Tests (Playwright)): Running 83 tests using 1 worker 1 failed 1) tests/e2e/spec-coverage/_sentinel.spec.ts:24:6 › SENTINEL … 4 skipped 78 passed (5.3m) The sentinel was the SOLE failure and it turned the job conclusion red, so this suite demonstrably reports failures — every other pass in it now means something. The control has done its job; remove it. The same run also confirms the un-deadening. Against the previous run (31167880581: 82 tests, 76 passed, 6 skipped): - `✓ 55 … the "Add" action on Prohibitions opens the extracted ProhibitionFormModal (3.1s)` - `✓ 56 … the "Add" action on StandingConsents opens the (now-wired) StandingConsentFormModal (3.1s)` Both had skipped on every previous run. They now execute the journey they claim to cover and pass, so skipped fell 6 → 4 and passed rose 76 → 78. Note that the Prohibitions one passes: #333 blocks the LIST, not the Add CTA, so no new defect is hiding behind the old self-skip. Also seeds `localStorage['cn-support-dialog-shown:docudesk'] = '1'` in globalSetup. `_helpers.ts:dismissOverlays()` already CLOSES CnSupportDialog and that is why this suite has been clean — but closing it is reactive and racy. CnAppRoot uses `useSupportDialog(appId, { persistence: 'server' })`, where `visible` only flips true once the preferences GET RESOLVES, which can land after dismissOverlays has run and a spec has started clicking; the modal then mounts a full-viewport `.modal-mask` that swallows pointer events. Sibling repo opencatalogi hit exactly that on run 31167878145 (`1 flaky`, call log naming `data-testid-modal="cn-support-dialog" … subtree intercepts pointer events`). `resolveServerVisibility()` checks the local flag first and returns early, so seeding it means the dialog is never scheduled. dismissOverlays stays as the fallback. No spec asserts on CnSupportDialog, so nothing is weakened. Expected on this commit: 82 tests, 78 passed, 4 skipped, 0 failed, 0 flaky. * test(e2e): drop three dead networkidle waits (gate-58 / ADR-074 rule 4) Run 31174480451 turned `Hydra Gates` red with exactly one finding: [gate-58] e2e-networkidle: FAIL — 3 networkidle wait(s) in changed e2e file(s) — never settles on Nextcloud (ADR-074 rule 4) The gate is diff-scoped, so these three were pre-existing and my edits pulled them into scope. Fixing rather than leaving them: all three were `waitForLoadState('networkidle').catch(() => {})`, and the `.catch()` is the tell. Nextcloud holds long-lived connections open (notifications polling, user-status heartbeat), so networkidle NEVER fires — every one of these ran to its own timeout and was then swallowed. They did not "wait for the app to settle"; they burned the budget and continued at exactly the moment they would have anyway, while reading like a deliberate synchronisation point. - global-setup.ts — intent was "let the login bundle mount before typing". Replaced with the deterministic form of that intent: wait for `button[type="submit"]` to be visible. It is rendered by the same bundle that attaches the submit handler, and the very next statement clicks it. - orphaned-surface-restoration.spec.ts x2 — both were followed immediately by web-first assertions (`toHaveURL`, `toBeVisible`) which already retry until the SPA has routed, so the wait bought nothing. Removed; the surrounding `waitForTimeout(800)` is kept.⚠️ Note for whoever touches these comments next: gate-58 matches the literal `waitForLoadState('networkidle')`, so a comment that quotes the call in full trips the gate as if it were code. The first draft of this commit did exactly that and still showed 3 matches after the code was already clean. The comments therefore say "`networkidle` load-state wait" instead. Verified with the gate's own regex: 0 matches in both files.
Measured 61.41% on two independent development push runs (31045437704 and 31074205317); .coverage-baseline said 61.39, so Coverage Baseline Check failed on the non-empty git diff left by --update-baseline. Raises the baseline to the measured value, as the job's own error text asks. No threshold weakened, no waiver.
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.