Skip to content

Enforce the config guards that were declared but never checked (#222) - #242

Merged
silverbackdan merged 1 commit into
mainfrom
fix/222-config-guards
Sep 21, 2026
Merged

silverbackdan merged 1 commit into
mainfrom
fix/222-config-guards

Conversation

@silverbackdan

Copy link
Copy Markdown
Collaborator

Closes #222. Same defect as #214, in the four places it left behind.

The bug

->arrayNode('user')
    ->addDefaultsIfNotSet()                        // "if absent, use defaults"
    ->children()
        ->scalarNode('class_name')->isRequired()   // "must be provided"

Those contradict, and Symfony resolves it by ignoring the second. ArrayNode::finalizeValue() inserts the default at :227 and continues without finalising the child — and finalisation is where isRequired() is checked. So omitting the node skips every guard inside it.

The hole is exactly and only "node omitted entirely": a present-but-partial node is still validated correctly, which is why this survived.

Nothing here was a breaking change, and that was verified rather than assumed

Omitted Warnings What happens today
user 2 TypeError at SilverbackApiComponentsExtension.php:137 — container will not compile
refresh_token 4 TypeError at :104 — container will not compile
publishable 5 compiles clean, null wired into five services
refresh_token.options.class 1 compiles clean, null into RefreshTokenRepository
full valid config 0 clean

For publishable, the null genuinely lands:

PublishableStatusChecker   $permission            wired=NULL   declared=string (no default)
RouteExtension             $publicationPermission wired=NULL   declared=string (has default)
RouteVoter                 $publicationPermission wired=NULL   declared=string (has default)
RouteNormalizer            $publicationPermission wired=NULL   declared=string (no default)
SiteConfigParameterVoter   $permission            wired=NULL   declared=string (no default)

An explicitly passed null overrides a constructor default, so the two that look safe are not. Every one of those throws on instantiation — in practice a 500 on any API request.

So there is no working installation relying on the silent-null path, because that path does not lead anywhere that works. Both TypeErrors are thrown from this bundle's own extension, not a dependency; nothing downstream catches any of it.

The fix, and why it is not ->validate()

isRequired() goes on the array node itself, which ArrayNode::finalizeValue() checks at :214 — before inserting the default at :227. So a required node is enforced on omission while its children still resolve their defaults when it is present.

This corrects the convention recorded after #214, which said to use a node-level ->validate() instead. validate() runs in finalize(), i.e. only when the node is present — which is precisely the case that already worked. It would have changed nothing.

validate() is right for exactly one of the four: refresh_token.options.class, which is genuinely conditional on the storage handler. That key is not in the config tree at all (options is a free-form useAttributeAsKey map), so it is a mandatory setting that was never declared.

Before and after

Case Before After
omit user TypeError, Symfony internal in the message The child config "user" under "silverback_api_components" must be configured.
omit refresh_token TypeError, Symfony internal named config error
omit publishable compiles, then 500s on every request named config error
doctrine handler, no options.class compiles, then TypeError message naming the key
custom handler, no options.class OK still OK
defaulted children resolve still resolve (repeat_ttl_seconds = 86400)

The last two rows are the guards. isRequired() on a node could plausibly have suppressed addDefaultsIfNotSet() on its children; it does not, and a custom storage handler is correctly unaffected.

Nothing existing breaks: SilverbackApiComponentsExtensionTest's minimal config uses a custom handler, and the functional app already sets options.class.

Tests

7 cases added to ConfigurationTest, written first and watched fail — 4 of 5 failed on main, and the one that passed was the control proving a present node is still validated.

PHPUnit 711 → 718. Behat unchanged at 550.

Also filed: components-web-app/docs#8 — configuration.md currently asserts the opposite of the actual behaviour, and says the user.email_verification booleans are required when #214 made them optional.

🤖 Generated with Claude Code

user, refresh_token and publishable each declared isRequired() children
under a node carrying addDefaultsIfNotSet(). ArrayNode::finalizeValue()
inserts the default and continues without finalising, and finalisation is
where isRequired() is checked, so omitting the node skipped every guard
inside it.

Omitting user or refresh_token then died with a TypeError naming a Symfony
internal; omitting publishable compiled clean and wired null into five
services declaring string, which fails on first use. None of the four had a
working configuration to preserve.

The guard belongs on the array node itself, which is checked before the
default is inserted. A node-level validate() only runs when the node is
present, so it cannot express required presence — it is used here for the
one case that is genuinely conditional, the doctrine storage handler needing
an entity class.
@codecov

codecov Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.40%. Comparing base (20aabaf) to head (ae5747b).

Additional details and impacted files
@@             Coverage Diff              @@
##               main     #242      +/-   ##
============================================
+ Coverage     88.39%   88.40%   +0.01%     
- Complexity     2664     2665       +1     
============================================
  Files           258      258              
  Lines          7718     7726       +8     
============================================
+ Hits           6822     6830       +8     
  Misses          896      896              
Flag Coverage Δ
behat 69.10% <0.00%> (-0.08%) ⬇️
phpunit 36.21% <100.00%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@silverbackdan
silverbackdan merged commit 973d9b0 into main Sep 21, 2026
12 of 13 checks passed
@silverbackdan
silverbackdan deleted the fix/222-config-guards branch September 21, 2026 15:26
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.

Config guards that are declared but never enforced: user.class_name, refresh_token.*, publishable.permission

1 participant