Skip to content

Refuse on the API what demo mode refuses in the interface - #831

Merged
blaipr merged 2 commits into
mainfrom
fix/the-api-honours-demo-mode
Aug 20, 2026
Merged

Refuse on the API what demo mode refuses in the interface#831
blaipr merged 2 commits into
mainfrom
fix/the-api-honours-demo-mode

Conversation

@blaipr

@blaipr blaipr commented Aug 20, 2026

Copy link
Copy Markdown
Member

The gap

Demo mode makes an instance refuse to change or copy itself. The web enforces it in five config
actions and in UserForm:

web API
ConfigBackup/FileBackupController · DownloadBackupDbController refuses
ConfigImport/ImportController refuses
ConfigEncryption/SaveController · RefreshController refuses
UserForm — edit / delete / change password of the demo account refuses
config/backup ran it
config/export ran it
users/{id} PUT / DELETE ran it

grep -rn isDemoEnabled src/Infrastructure/Adapter/In/Api/ returned nothing: the API surface did
not mention demo mode anywhere.

Why this one matters more than a missing guard usually would

A demo deployment is the one place where the caller is meant to hold administrator credentials —
they are published so people can try the thing. The ACL therefore stops nobody, and the demo guard
is the whole boundary. Sign in as the demo admin, mint a token, call the API, and it did the backup,
the export or the user change the interface had just refused one click earlier. A visitor who
changed the demo admin's password ended the demo for everyone who came after.

The change

One shared denyOnDemo() on the API ControllerBase rather than four copies, plus a
denyOnDemoUser(int $id) that narrows it to the published account. Wired into config/backup,
config/export, and the user edit and delete paths — the complete set: there is no API
password-change endpoint for users, so the web's third isDemo() call site has no second door.

The demo account's id moves from a private constant in UserForm to User::DEMO_ADMIN_ID, so the
two doors compare against the same value instead of each holding a copy.

The user refusals are deliberately narrow. Every other user on a demo stays editable and removable —
trying that is most of the point of running a demo.

Test

DemoModeTest (5 tests, real ApiTestCase dispatch with a real token, demo mode written into the
config the API actually reads). Each refusal asserts both the error and the state — the row is
unchanged, no dump or export file is written — and is paired with the same call succeeding on an
ordinary user, so none of them can be satisfied by an endpoint that simply stopped working.

Mutation-checked: removing the guard fails exactly the four refusals, at the assertion that says the
API refused, and leaves the control passing.

OK (3994 tests, 36797 assertions)   unit
OK (981 tests, 2917 assertions)     integration

PHPStan level 6 and PHPCS clean.

Also

CLAUDE.md gains "The same rule, asked at the other door" in the defects section. This is the
fourth finding from that lens — after the custom-field masking, the password-policy lifetime on edit,
and the search-paging clamp — and it had no entry, while the pattern it describes keeps producing.

blaipr added 2 commits August 20, 2026 20:32
Demo mode makes an instance refuse to change or copy itself. The web enforces
it in five config actions and in UserForm; nothing on the API surface mentioned
demo mode anywhere.

That gap matters more than a missing guard usually would, because a demo
deployment is the one place where the caller is *meant* to hold administrator
credentials — they are published so people can try it. The ACL therefore stops
nobody: sign in as the demo admin, mint a token, and the API ran the backup, the
export, or the user change the interface had just refused. A visitor who changed
the demo admin's password ended the demo for everyone after them.

Four endpoints gain the guard, through one shared method on the API
ControllerBase rather than four copies: config/backup, config/export, and the
user edit and delete paths. The demo account's id moves from a private constant
in UserForm to User::DEMO_ADMIN_ID, so the two doors compare against the same
value.

The refusal is narrowed to that one account on the user endpoints — every other
user on a demo stays editable and removable, which is most of what there is to
try — and DemoModeTest pairs each refusal with the same call succeeding on an
ordinary user, so none of them can be satisfied by an endpoint that simply
stopped working. Removing the guard fails exactly the four refusals and leaves
the control passing.
@blaipr
blaipr merged commit c4be68c into main Aug 20, 2026
8 checks passed
@blaipr
blaipr deleted the fix/the-api-honours-demo-mode branch August 20, 2026 18:44
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.

1 participant