Skip to content

Release: merge development into beta - #71

Open
github-actions[bot] wants to merge 1373 commits into
betafrom
development
Open

Release: merge development into beta#71
github-actions[bot] wants to merge 1373 commits into
betafrom
development

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR to sync development changes to beta for beta release.

Merging this PR will trigger the beta release workflow.

Reminder: Add a major, minor, or patch label to this PR to control the version bump. Default is patch.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report

Repository ConductionNL/docudesk
Commit 1a6f970
Branch 71/merge
Event pull_request
Generated 2026-03-19 19:06 UTC
Workflow Run https://github.com/ConductionNL/docudesk/actions/runs/23312024194

Summary

Group Result
PHP Quality PASS
Vue Quality PASS
Security PASS
License PASS
PHPUnit PASS
Newman SKIP

PHP Quality

Tool Result
lint PASS
phpcs PASS
phpmd PASS
psalm PASS
phpstan PASS
phpmetrics PASS

Vue Quality

Tool Result
eslint PASS
stylelint PASS

Security

Ecosystem Result
composer PASS
npm PASS

License Compliance

Ecosystem Result
composer PASS
npm PASS

composer dependencies (110 total)

Metric Count
Approved (allowlist) 110
Approved (override) 0
Denied 0

npm dependencies (266 total)

Metric Count
Approved (allowlist) 265
Approved (override) 1
Denied 0

PHPUnit Tests

PHP Nextcloud Result
Overall PASS

Code coverage: 0% (0 / 10 statements)

Integration Tests (Newman)

Newman integration tests were not enabled for this run.


Generated automatically by the Quality workflow.

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report

Repository ConductionNL/docudesk
Commit cc14e0c
Branch 71/merge
Event pull_request
Generated 2026-03-19 21:38 UTC
Workflow Run https://github.com/ConductionNL/docudesk/actions/runs/23318043060

Summary

Group Result
PHP Quality PASS
Vue Quality PASS
Security PASS
License PASS
PHPUnit PASS
Newman SKIP

PHP Quality

Tool Result
lint PASS
phpcs PASS
phpmd PASS
psalm PASS
phpstan PASS
phpmetrics PASS

Vue Quality

Tool Result
eslint PASS
stylelint PASS

Security

Ecosystem Result
composer PASS
npm PASS

License Compliance

Ecosystem Result
composer PASS
npm PASS

composer dependencies (110 total)

Metric Count
Approved (allowlist) 110
Approved (override) 0
Denied 0

npm dependencies (266 total)

Metric Count
Approved (allowlist) 265
Approved (override) 1
Denied 0

PHPUnit Tests

PHP Nextcloud Result
Overall PASS

Code coverage: 0% (0 / 10 statements)

Integration Tests (Newman)

Newman integration tests were not enabled for this run.


Generated automatically by the Quality workflow.

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report

Repository ConductionNL/docudesk
Commit 7531e25
Branch 71/merge
Event pull_request
Generated 2026-03-22 21:13 UTC
Workflow Run https://github.com/ConductionNL/docudesk/actions/runs/23412628554

Summary

Group Result
PHP Quality FAIL
Vue Quality FAIL
Security FAIL
License FAIL
PHPUnit FAIL
Newman FAIL

PHP Quality

Tool Result
lint FAIL
phpcs FAIL
phpmd FAIL
psalm FAIL
phpstan FAIL
phpmetrics FAIL

Vue Quality

Tool Result
eslint FAIL
stylelint FAIL

Security

Ecosystem Result
composer FAIL
npm FAIL

License Compliance

Ecosystem Result
composer FAIL
npm FAIL

PHPUnit Tests

PHP Nextcloud Result
Overall FAIL

Integration Tests (Newman)

Result
FAIL

Generated automatically by the Quality workflow.

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report

Repository ConductionNL/docudesk
Commit 7f052ed
Branch 71/merge
Event pull_request
Generated 2026-03-22 21:15 UTC
Workflow Run https://github.com/ConductionNL/docudesk/actions/runs/23412642650

Summary

Group Result
PHP Quality PASS
Vue Quality PASS
Security PASS
License PASS
PHPUnit FAIL
Newman SKIP

PHP Quality

Tool Result
lint PASS
phpcs PASS
phpmd PASS
psalm PASS
phpstan PASS
phpmetrics PASS

Vue Quality

Tool Result
eslint PASS
stylelint PASS

Security

Ecosystem Result
composer PASS
npm PASS

License Compliance

Ecosystem Result
composer PASS
npm PASS

composer dependencies (110 total)

Metric Count
Approved (allowlist) 110
Approved (override) 0
Denied 0

npm dependencies (266 total)

Metric Count
Approved (allowlist) 265
Approved (override) 1
Denied 0

PHPUnit Tests

PHP Nextcloud Result
Overall FAIL

Integration Tests (Newman)

Newman integration tests were not enabled for this run.


Generated automatically by the Quality workflow.

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report

Repository ConductionNL/docudesk
Commit 6c9e313
Branch 71/merge
Event pull_request
Generated 2026-03-22 21:20 UTC
Workflow Run https://github.com/ConductionNL/docudesk/actions/runs/23412678437

Summary

Group Result
PHP Quality PASS
Vue Quality PASS
Security PASS
License PASS
PHPUnit PASS
Newman SKIP

PHP Quality

Tool Result
lint PASS
phpcs PASS
phpmd PASS
psalm PASS
phpstan PASS
phpmetrics PASS

Vue Quality

Tool Result
eslint PASS
stylelint PASS

Security

Ecosystem Result
composer PASS
npm PASS

License Compliance

Ecosystem Result
composer PASS
npm PASS

composer dependencies (110 total)

Metric Count
Approved (allowlist) 110
Approved (override) 0
Denied 0

npm dependencies (266 total)

Metric Count
Approved (allowlist) 265
Approved (override) 1
Denied 0

PHPUnit Tests

PHP Nextcloud Result
Overall PASS

Code coverage: 0% (0 / 10 statements)

Integration Tests (Newman)

Newman integration tests were not enabled for this run.


Generated automatically by the Quality workflow.

Download the full PDF report from the workflow artifacts.

rubenvdlinde and others added 24 commits June 27, 2026 13:51
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>
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>
rubenvdlinde and others added 30 commits August 4, 2026 01:00
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants