fix(settings): implement settings#update — PUT /api/settings returned 500 - #434
Merged
Conversation
The canonical AppHost route table (`OCA\OpenRegister\AppHost\Routes::standard()`)
ships `['name' => 'settings#update', 'url' => '/api/settings', 'verb' => 'PUT']`,
and decidesk's own openregister-absent fallback in `appinfo/routes.php`
re-declares it verbatim (decidesk#377) — so the route is live on BOTH paths.
`AppHost\Bootstrap::aliasControllerUnlessLeafDefinesIt()` only substitutes
OpenRegister's `GenericSettingsController` when the leaf does NOT ship a class
of that name. decidesk ships `lib/Controller/SettingsController.php`, so the
alias is skipped and decidesk owes every method the canonical table routes to
`settings#`. It had index/create/load but no update().
Measured 2026-08-08 on the dev instance:
GET /apps/decidesk/api/settings -> 200 (positive control)
PUT /apps/decidesk/api/settings -> 500
ReflectionException: Method
OCA\Decidesk\Controller\SettingsController::update() does not exist
at lib/private/AppFramework/Utility/ControllerMethodReflector.php:40
Moves the create() body into update() and makes create() a delegate, so the
POST alias (still used by src/store/modules/settings.js::saveSettings) keeps
byte-identical behaviour. Both carry #[AuthorizedAdminSetting] — the middleware
only evaluates attributes on the DISPATCHED method, so delegation inherits
nothing, and the write reaches instance-wide IAppConfig.
Adds two unit test files, each with a positive control so a green cannot be
produced vacuously.
rubenvdlinde
requested review from
WilcoLouwerse,
bbrands02 and
rjzondervan
as code owners
August 8, 2026 16:00
Contributor
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 549/549 | |||
| PHPUnit | ✅ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-08 16:35 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
\OCA\OpenRegister\AppHost\Routes::standard()ships the canonical route['name' => 'settings#update', 'url' => '/api/settings', 'verb' => 'PUT'].decidesk's
appinfo/routes.phpcallsRoutes::standard($extra)behind aclass_exists()guard and its openregister-absent fallback re-declaressettings#updateverbatim (decidesk#377) — so the route is live on bothcode paths.
AppHost\Bootstrap::aliasControllerUnlessLeafDefinesIt()only substitutesOpenRegister's
GenericSettingsControllerwhen the leaf does not ship aclass of that name. decidesk does ship
lib/Controller/SettingsController.php,so the alias is skipped and decidesk owes every method the canonical table
routes to
settings#. It hadindex/create/load/getPublicationConfig/setPublicationConfig— but noupdate().The router matches the URL and the dispatcher reflects the method, so this is a
500, not a 404.
Pre-fix failure evidence (measured 2026-08-08, dev instance, NC 34.0.0.12)
nextcloud.log, samereqId:What
update()writes, and why that matches decidesk's settings surfaceupdate()is the existingcreate()body, moved unchanged:SettingsService::updateSettings()writes the whitelistedCONFIG_KEYS(register, ORI endpoint, email-voting flag, the three schema slugs, the
organisation name/logo/timezone/locale/currency/retention keys,
organisatie_modus, …) into the instance-wideIAppConfig, then returnsgetSettings()— the refreshed map, withSECRET_KEYSomitted (they arewrite-only and never echoed). So
update()returns what was actually stored,not the submission. That is exactly the canonical
GenericSettingsControllerBase::update()shape.getPublicationConfig/setPublicationConfigwere deliberately NOT foldedin. They live on a different URL (
/api/settings/publication-config), arebacked by a different service (
PublicationConfigService, per-governance-bodyrecords), and their keys are not in
SettingsService::CONFIG_KEYS— theapp's own settings payload genuinely does not include them. Folding them in
would change the response shape of both
create()andupdate().Auth posture — and why
update()carries#[AuthorizedAdminSetting(AdminSettings::class)], the sameposture as
create(), because it is literally the same write path.Reasoning:
SettingsService::updateSettings()writes instance-wideIAppConfig, notper-user state. Any authenticated user reaching it could repoint the app's
register, schema slugs and ORI endpoint for everybody.
index(),getPublicationConfig()) carry#[NoAdminRequired]plus an in-body sessioncheck. That posture was not copied. Copying a read's
#[NoAdminRequired]onto a write is exactly how docudesk nearly shipped a PII hole.
create()keeps its own attribute even though its body is nowreturn $this->update();. Nextcloud's middleware evaluates auth attributeson the dispatched method only, so delegation inherits nothing.
create()had no in-body guard to move — the whole guard is the attribute — and the
delegate path is therefore still guarded at both entry points.
A unit test (
testBothWriteMethodsRequireAdmin) asserts each write methodcarries
AuthorizedAdminSettingand does not carryNoAdminRequired, so afuture posture regression on either entry point is caught.
Can-fail proof
The pre-fix tree was reconstructed by hand with the Edit tool (
update()removed,
create()'s original inline body restored) — notgit stash, notgit checkout --.RED (pre-fix tree):
The reflection test reproduces the same exception text the live 500
produced. Note the two route-table tests stayed green in the red run — that
is the point: the route is declared and live, and only the method is missing.
GREEN (this branch):
Full unit suite on this branch:
OK, but some tests were skipped! Tests: 805, Assertions: 2813, Skipped: 29(798 before this PR; the 29 skips arepre-existing).
Tests
tests/Unit/AppInfo/CanonicalRouteMethodContractTest.phpdecidesk ships on disk, each individual routed method must exist, be public,
and be non-static. Merely
class_exists()is explicitly not enough — thefile-on-disk probe is what makes the AppHost skip the alias, and a booted
container would satisfy
class_exists()via the alias target itself.appinfo/routes.phpstilldeclares every canonical route name, and pins
'settings#update' / '/api/settings' / 'PUT'with a regex so a nameappearing only in a comment cannot satisfy it.
$inspected > 0on the reflection loop and$checked > 0on the route-name loop, so an empty finding list cannot beproduced vacuously by a broken path probe.
tests/Unit/Controller/SettingsControllerWriteTest.phpupdate()reachesSettingsService::updateSettings()with the request'sown params and returns the refreshed config the service stored (not the
submission).
create()delegates and still performs the identical write — it is not anempty success.
identical to a successful no-op from the caller's side).
$checked === 2positive control.
Both new methods' statements are covered, so the coverage ratchet is not
waived.
Gates
Run from a clean extract of
ConductionNL/.githubatorigin/main(the local.githubcheckout is 77 commits behind, so it is a different program), scopedto the diff against
origin/development, reading stdout, not the exit byte:Honest residue: 3 applicable gates did not run and their subject matter is
unverified by this run —
gate-19 e2e-coverage(the diff touches no specfile),
gate-62 store-planeandgate-63 settings-surface(the diff touchesno manifest or menu-layout). This is not "all 64 green".
phpcs: 0 errors on the changed file; 1 pre-existing warning (the REUSE SPDXheader line, which the ruleset deliberately tolerates).
Not verified
The fix itself was not re-probed live: the dev instance bind-mounts the main
checkout, not this worktree, and deploying to the shared instance is out of
scope for this PR. The 500 above is the pre-fix measurement; the fix is
evidenced by the unit-level can-fail proof.