Manually update & delete host IdP mappings - #35325
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #35325 +/- ##
==========================================
- Coverage 66.24% 66.19% -0.05%
==========================================
Files 2104 2089 -15
Lines 178307 177484 -823
Branches 7424 7234 -190
==========================================
- Hits 118122 117493 -629
+ Misses 49363 49158 -205
- Partials 10822 10833 +11
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
| @@ -437,6 +437,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC | |||
| // Deprecated: Device mappings are included in the host details endpoint: /api/_version_/fleet/hosts/{id} | |||
| ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", listHostDeviceMappingEndpoint, listHostDeviceMappingRequest{}) | |||
| ue.PUT("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", putHostDeviceMappingEndpoint, putHostDeviceMappingRequest{}) | |||
| ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", deleteHostIDPEndpoint, deleteHostIDPRequest{}) | |||
There was a problem hiding this comment.
I would make this change to make it clearer to customers which kind of device mapping this endpoint is deleting
| ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", deleteHostIDPEndpoint, deleteHostIDPRequest{}) | |
| ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping/idp", deleteHostIDPEndpoint, deleteHostIDPRequest{}) |
6766030 to
948f2ac
Compare
d1d9fe4 to
2b0b187
Compare
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
WalkthroughImplement manual IdP (Identity Provider) host mapping management by replacing the Add End User modal with an Update/Delete-capable modal, adding a DELETE API endpoint, and integrating activity logging across the frontend UI and backend service layers. Changes
Sequence DiagramsequenceDiagram
participant UI as User Interface
participant Modal as UpdateEndUserModal
participant Service as Frontend Service
participant Backend as Backend API
participant Store as Datastore
participant Log as Activity Log
UI->>Modal: Open modal (edit/add IdP username)
Modal->>Modal: Render form with existing or empty username
UI->>Modal: Submit username
Modal->>Service: updateHostIdp(hostId, idpUsername)
Service->>Backend: PUT /fleet/hosts/{id}/device_mapping
Backend->>Store: SetHostDeviceMapping(...)
Store->>Log: Create EditedHostIdpData activity
Store-->>Backend: Success
Backend-->>Service: 200 OK
Service-->>Modal: Success
Modal->>UI: Show toast, close modal
UI->>Modal: Open delete flow
Modal->>Service: deleteHostIdp(hostId)
Service->>Backend: DELETE /fleet/hosts/{id}/device_mapping/idp
Backend->>Store: DeleteHostIDP(...)
Store->>Log: Create EditedHostIdpData activity (empty username)
Store-->>Backend: Success
Backend-->>Service: 200 OK
Service-->>Modal: Success
Modal->>UI: Show toast, close modal
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Areas requiring attention during review:
Possibly related PRs
Suggested reviewers
Pre-merge checks and finishing touches❌ Failed checks (2 warnings, 1 inconclusive)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (13)
frontend/interfaces/host.ts (1)
311-313: Encode the “at most 1 end user” invariant in the type (or clarify).If there is truly max 1 item, consider:
- end_user?: IHostEndUser (singular), or
- end_users?: [IHostEndUser] to enforce a 0–1 tuple.
If changing the shape is risky now, add TSDoc clarifying consumers should only read index 0. Your call.
changes/34222-manual-IdP-update-ui-delete-activities (1)
1-3: Include concrete API and activity identifiers.Add explicit path and identifiers to aid release notes and QA:
- DELETE /api/v1/fleet/hosts/{id}/device_mapping/idp
- ActivityType: "edited_host_idp_data"
- Details fields: host_id, host_display_name, host_idp_username
frontend/interfaces/activity.ts (1)
262-263: Document semantics: empty vs undefined host_idp_username.If deletion is represented by empty string or by omitting the field, document it here to keep frontend logic consistent across consumers.
frontend/services/entities/hosts.ts (1)
677-684: Implement input validation and clarify API field semantics.Endpoints are correctly defined and both methods are actively used in
HostDetailsPage.tsx(lines 900, 903). However, the suggested improvements should still be applied:
- Add clarifying comment about the "email" field carrying IdP username
- Move trim logic to service layer for robustness; while the UI checks for empty string (line 899 in
HostDetailsPage.tsx), the service should be defensive and trim input directlyProposed implementation at
frontend/services/entities/hosts.tslines 677-680:updateHostIdp(hostId: number, idpUsername: string) { const path = endpoints.HOST_DEVICE_MAPPING(hostId); + const username = idpUsername?.trim(); + // Note: API uses "email" field to carry IdP username for source "idp". + if (!username) { + return this.deleteHostIdp(hostId); + } - return sendRequest("PUT", path, { source: "idp", email: idpUsername }); + return sendRequest("PUT", path, { source: "idp", email: username }); },server/mock/datastore_mock.go (1)
264-265: New mock hook for DeleteHostIDP added — consistent with pattern.Signature and placement align with existing host mapping funcs. No changes requested. Optional: if feasible at interface level, prefer naming the param hostID for clarity (this file is autogenerated, so not here).
server/service/hosts_test.go (1)
3314-3363: Consider expanding test coverage for DeleteHostIDP.The test covers basic license validation, which is good. However, compared to similar tests in this file (e.g.,
TestSetHostDeviceMapping,TestLockUnlockWipeHostAuth), the coverage is limited. Consider adding:
Authorization test cases for different user roles:
- Global/team observer (should fail)
- Global/team maintainer (should succeed on premium)
- Team admin for wrong team (should fail)
Error handling scenarios:
- Host not found (HostLiteFunc returns error)
- DeleteHostIDPFunc returns an error
- NewActivityFunc returns an error
Parameter validation in mocks to verify correct values are passed:
ds.DeleteHostIDPFunc = func(ctx context.Context, id uint) error { + require.Equal(t, uint(1), id) return nil } ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error { + act, ok := activity.(fleet.ActivityTypeEditedHostIdpData) + require.True(t, ok) + require.Equal(t, uint(1), act.HostID) return nil }These additions would bring the test coverage in line with similar functionality in this file.
server/datastore/mysql/hosts.go (1)
3874-3874: Consider renaming parameter for consistency.The parameter
idrepresents a host ID. For consistency with other functions in this file (e.g.,deleteHostSCIMUserMappingat line 4428,associateHostWithScimUserat line 4411), consider renaming it tohostIDto improve code clarity.-func (ds *Datastore) DeleteHostIDP(ctx context.Context, id uint) error { +func (ds *Datastore) DeleteHostIDP(ctx context.Context, hostID uint) error { delStmt := `DELETE FROM host_emails WHERE host_id = ? AND source = ?` err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var idpDelRes, mdmIdpDelRes sql.Result - idpDelRes, err := tx.ExecContext(ctx, delStmt, id, fleet.DeviceMappingIDP) + idpDelRes, err := tx.ExecContext(ctx, delStmt, hostID, fleet.DeviceMappingIDP) if err != nil { return ctxerr.Wrap(ctx, err, "delete existing IdP device mappings") } idpRowsAffected, err := idpDelRes.RowsAffected() if err != nil { return ctxerr.Wrap(ctx, err, "delete existing IdP device mappings - get IdP rows affected") } - mdmIdpDelRes, err = tx.ExecContext(ctx, delStmt, id, fleet.DeviceMappingMDMIdpAccounts) + mdmIdpDelRes, err = tx.ExecContext(ctx, delStmt, hostID, fleet.DeviceMappingMDMIdpAccounts) if err != nil { return ctxerr.Wrap(ctx, err, "delete existing MDM IdP device mappings") } mdmIdpRowsAffected, err := mdmIdpDelRes.RowsAffected() if err != nil { return ctxerr.Wrap(ctx, err, "delete existing IdP device mappings - get mdm IdP rows affected") } if idpRowsAffected+mdmIdpRowsAffected == 0 { return fleet.NewInvalidArgumentError("delete host IdP mapping", "no existing IdP mappings for this host") } - if err := deleteHostSCIMUserMapping(ctx, tx, id); err != nil { + if err := deleteHostSCIMUserMapping(ctx, tx, hostID); err != nil { return ctxerr.Wrap(ctx, err, "delete existing host SCIM user mapping") } return nil }) return err }frontend/pages/hosts/details/cards/User/components/UpdateEndUserModal/UpdateEndUserModal.tsx (5)
5-7: Avoid ts-ignore on InputField import.Silencing types hides real issues. Import correctly or add a local module declaration.
Example:
-// @ts-ignore -import InputField from "components/forms/fields/InputField"; +import InputField from "components/forms/fields/InputField";If types are missing, add a minimal ambient declaration:
// types/global.d.ts declare module "components/forms/fields/InputField" { import * as React from "react"; export interface InputFieldProps { label: string; name: string; value: string; onChange: (val: string) => void; helpText?: string; autoFocus?: boolean; } const InputField: React.FC<InputFieldProps>; export default InputField; }
31-35: Trim input and avoid submitting unchanged values.
- Trim whitespace before update.
- Disable Save when unchanged to cut no‑op API calls.
- const [idpUsername, setIdpUsername] = useState(userNameDisplayValue || ""); + const [idpUsername, setIdpUsername] = useState(userNameDisplayValue || ""); + const trimmed = idpUsername.trim(); + const isUnchanged = trimmed === (userNameDisplayValue || ""); - const onSave = () => { - onUpdate(idpUsername); - }; + const onSave = () => { + onUpdate(trimmed); + };And update the disabled prop (see next comment).
Also applies to: 36-38
46-65: Handle Enter key and button semantics; prevent default form submit.
- Add onSubmit to the form and set Button type="submit" so Enter saves.
- Prevent default submit to avoid navigation.
- Disable Save while updating, when adding with empty value, and when unchanged.
- <> - <form> + <> + <form + onSubmit={(e) => { + e.preventDefault(); + onSave(); + }} + > <InputField label="Username (IdP)" name="username_idp" value={idpUsername} onChange={(val: string) => setIdpUsername(val)} helpText="This will be used to populate additional user data, e.g. full name and department." + autoFocus /> <div className="modal-cta-wrap"> <Button + type="submit" isLoading={isUpdating} - disabled={isUpdating || (!isEditing && idpUsername === "")} - onClick={onSave} + disabled={isUpdating || (!isEditing && trimmed === "") || isUnchanged} > Save </Button> </div> </form> </>Also applies to: 55-63, 69-77
24-30: Consider explicit delete UX to use the new DELETE endpoint.Relying on “clear username + Save” couples UI to backend heuristics. Expose an optional onDelete prop and show a Delete action when editing.
interface IUpdateEndUserModalProps { isPremiumTier: boolean; /** There will be at most 1 end user */ endUsers: IHostEndUser[]; onUpdate: (username: string) => void; isUpdating?: boolean; onExit: () => void; + onDelete?: () => void; } ... <Modal title={isEditing ? "Edit user" : "Add user"} onExit={onExit} className={baseClass} > {renderContent()} </Modal>And render a secondary “Delete” button when isEditing && onDelete to call the DELETE path. Please confirm desired UX.
Also applies to: 69-77
31-35: Sync local state when endUsers prop changes.If the modal remains open across updates, keep the field in sync.
+ React.useEffect(() => { + setIdpUsername(generateUsernameValues(endUsers)[0] || ""); + }, [endUsers]);server/service/hosts.go (1)
1699-1759: Consider refactoring to eliminate redundant host fetch.The host is fetched at line 1710 for activity context, but it was already fetched during authorization at line 1675. This redundancy occurs because the
hostvariable from the authorization block is not in scope here.Consider refactoring to declare and fetch the host at the function level (after authorization checks) so it can be reused throughout the function, avoiding the duplicate database call.
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (24)
changes/34222-manual-IdP-update-ui-delete-activities(1 hunks)frontend/interfaces/activity.ts(2 hunks)frontend/interfaces/host.ts(1 hunks)frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx(2 hunks)frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx(1 hunks)frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx(13 hunks)frontend/pages/hosts/details/cards/User/User.tests.tsx(3 hunks)frontend/pages/hosts/details/cards/User/User.tsx(2 hunks)frontend/pages/hosts/details/cards/User/components/AddEndUserModal/AddEndUserModal.tsx(0 hunks)frontend/pages/hosts/details/cards/User/components/AddEndUserModal/_styles.scss(0 hunks)frontend/pages/hosts/details/cards/User/components/AddEndUserModal/index.ts(0 hunks)frontend/pages/hosts/details/cards/User/components/UpdateEndUserModal/UpdateEndUserModal.tsx(1 hunks)frontend/pages/hosts/details/cards/User/components/UpdateEndUserModal/index.ts(1 hunks)frontend/services/entities/hosts.ts(1 hunks)frontend/utilities/endpoints.ts(1 hunks)server/datastore/mysql/hosts.go(1 hunks)server/fleet/activities.go(2 hunks)server/fleet/datastore.go(1 hunks)server/fleet/service.go(1 hunks)server/mock/datastore_mock.go(3 hunks)server/mock/service/service_mock.go(3 hunks)server/service/handler.go(1 hunks)server/service/hosts.go(3 hunks)server/service/hosts_test.go(3 hunks)
💤 Files with no reviewable changes (3)
- frontend/pages/hosts/details/cards/User/components/AddEndUserModal/_styles.scss
- frontend/pages/hosts/details/cards/User/components/AddEndUserModal/AddEndUserModal.tsx
- frontend/pages/hosts/details/cards/User/components/AddEndUserModal/index.ts
🧰 Additional context used
📓 Path-based instructions (1)
**/*.go
⚙️ CodeRabbit configuration file
When reviewing SQL queries that are added or modified, ensure that appropriate filtering criteria are applied—especially when a query is intended to return data for a specific entity (e.g., a single host). Check for missing WHERE clauses or incorrect filtering that could lead to incorrect or non-deterministic results (e.g., returning the first row instead of the correct one). Flag any queries that may return unintended results due to lack of precise scoping.
Files:
server/fleet/service.goserver/datastore/mysql/hosts.goserver/fleet/activities.goserver/mock/service/service_mock.goserver/service/hosts.goserver/fleet/datastore.goserver/service/hosts_test.goserver/mock/datastore_mock.goserver/service/handler.go
🧠 Learnings (1)
📚 Learning: 2025-10-03T18:16:11.482Z
Learnt from: MagnusHJensen
Repo: fleetdm/fleet PR: 33805
File: server/service/integration_mdm_test.go:1248-1251
Timestamp: 2025-10-03T18:16:11.482Z
Learning: In server/service/integration_mdm_test.go, the helper createAppleMobileHostThenEnrollMDM(platform string) is exclusively for iOS/iPadOS hosts (mobile). Do not flag macOS model/behavior issues based on changes within this helper; macOS provisioning uses different helpers such as createHostThenEnrollMDM.
Applied to files:
server/service/hosts.goserver/service/hosts_test.go
🔇 Additional comments (21)
frontend/interfaces/activity.ts (1)
146-147: Enum addition looks good; ensure UI wires the new type.Confirm GlobalActivityItem getDetail handles EditedHostIdpData and that tests cover both update (non-empty username) and delete (empty/undefined).
frontend/pages/hosts/details/cards/User/components/UpdateEndUserModal/index.ts (1)
1-1: LGTM.Barrel re-export is correct and simplifies imports.
frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx (1)
698-701: Empty-state UX handling is already in place.The UserCard (User component) gracefully handles
endUsers=[]: all value generators return empty arrays when empty, optional fields hide conditionally (chrome profiles, other emails only render when data exists), and the write button only appears whencanWriteEndUseris true. Tests confirm this scenario works as intended. No action needed.server/fleet/service.go (1)
403-404: Add documentation to DataStore interface DeleteHostIDP and clarify implementation scope.From code inspection:
- DataStore interface (line 403) lacks a doc comment; Service interface (line 351) has one: "deletes an existing host IDP device mapping"
- License gating is present in tests (returns
fleet.ErrMissingLicensewhen unlicensed) but not documented in code- Service implementation comment (line 1808) claims "delete host IdP and SCIM mappings," but the datastore implementation only executes:
DELETE FROM host_emails WHERE host_id = ? AND source = ?. No explicit SCIM mapping deletion visible in that method- Idempotency semantics remain unclear (e.g., behavior when the mapping is already absent)
Add doc comments to the DataStore interface method and reconcile the implementation comment with actual behavior (particularly regarding SCIM mappings).
server/mock/datastore_mock.go (2)
1964-1966: DataStore fields wired for DeleteHostIDP.Fields and Invoked flag follow established conventions. Ensure tests set DeleteHostIDPFunc to avoid nil panics in mocks.
4813-4819: All verification checks passed; no issues found.Interface, service method, HTTP route, and unit tests are all in place:
- Datastore interface:
server/fleet/datastore.go:351- Service method:
server/service/hosts.go:1783- HTTP DELETE route:
server/service/handler.go:440- Unit test:
server/service/hosts_test.go:3314(TestDeleteHostDeviceIDPMapping)Mock implementation correctly follows the established pattern with proper locking and delegation.
server/fleet/datastore.go (1)
350-351: LGTM!The new
DeleteHostIDPmethod follows the established naming conventions and patterns in the interface. The method signature is appropriate for a deletion operation, accepting a context and ID parameter.frontend/utilities/endpoints.ts (1)
93-96: LGTM!The new endpoint constants are well-defined and follow the established patterns in this file. The endpoint paths are RESTful and the naming convention is consistent with other host-related endpoints.
frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx (2)
1610-1620: LGTM!The
editedHostIdpDatatemplate function correctly handles both setting and removing end-user IdP data. The logic to differentiate between the two cases (checking for empty string) is appropriate, and the message formatting is consistent with other activity templates in this file.
1975-1977: LGTM!The switch case for
ActivityType.EditedHostIdpDatais properly wired to the corresponding template function, following the established pattern used throughout thegetDetailfunction.server/service/hosts_test.go (2)
3167-3172: LGTM! Necessary mocks for activity logging.These mock additions support the new activity logging functionality in the IDP source success test case.
3205-3210: LGTM! Consistent mock additions.Same necessary mocks added for activity logging in the "IDP source success with any username when SCIM user not found" test case.
server/datastore/mysql/hosts.go (2)
3874-3908: Verify error type appropriateness for "no existing mappings" case.When no IDP mappings exist to delete, the function returns
fleet.NewInvalidArgumentError. Consider whether aNotFoundErrorwould be more semantically appropriate, since the issue is that no mapping was found rather than the host ID argument being invalid. The choice depends on the intended API semantics:
- If this operation should be idempotent (safe to call even when nothing exists), consider making it a no-op that succeeds
- If the caller is expected to know mappings exist before deleting,
NotFoundErrormight better signal the condition- If the current behavior is intentional, it's worth documenting why
InvalidArgumentErrorwas chosen
3874-3908: SQL queries are properly scoped and secure.The DELETE queries correctly filter by
host_idand use parameterized statements to prevent SQL injection. The function also properly executes within a transaction to ensure atomicity of the multi-table delete operation.server/fleet/activities.go (1)
242-244: Activity registered in ActivityDetailsList — LGTM.The new activity type is included in ActivityDetailsList so it will be documented and exposed. Good.
server/mock/service/service_mock.go (1)
243-244: Verification complete—all concerns addressed.The mock wiring is correct:
- Interface declares
DeleteHostIDP(ctx context.Context, id uint) error(server/fleet/service.go:403) ✓- Func type definition present (lines 243-244) ✓
- Struct fields
DeleteHostIDPFuncandDeleteHostIDPFuncInvokeddefined (lines 1185-1186) ✓- Method implementation follows pattern with lock and Invoked flag (lines 2877-2883) ✓
- Tests properly initialize
DeleteHostIDPFunc(hosts_test.go lines 3322, 3345) to prevent nil panics ✓Acronym casing inconsistency (IDP vs IdP vs idp) exists across the codebase but remains optional to address.
server/service/hosts.go (1)
1761-1824: LGTM! DeleteHostIDP implementation follows established patterns.The implementation correctly:
- Follows the authorization pattern (list → fetch host → write with team)
- Checks for premium license before allowing the operation
- Deletes the IdP mapping and cleans up SCIM mappings
- Logs activity with empty HostIdPUsername to indicate removal
- Handles errors appropriately
frontend/pages/hosts/details/cards/User/User.tests.tsx (1)
10-47: LGTM! Tests properly cover the updated User component API.The consolidated IdP test suite correctly verifies:
- All IdP data fields render properly (username, full name, groups, department)
- Write permission controls button visibility
- "Add user" button appears when no IdP username exists (with write permission)
- "Edit user" button appears when IdP username exists (with write permission)
- No buttons appear without write permission
frontend/pages/hosts/details/cards/User/User.tsx (1)
27-144: LGTM! User component API successfully simplified.The refactored component correctly:
- Simplifies props by removing platform-specific logic
- Provides clear write permission controls via
canWriteEndUser- Dynamically shows "Add user" vs "Edit user" based on endUsers presence
- Consistently renders all IdP fields with appropriate tooltips
- Maintains backward compatibility through default prop values
The new API is more intuitive and easier to use.
frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx (2)
896-913: LGTM! UpdateEndUser handler correctly implements delete and update flows.The implementation properly:
- Sets loading state before the operation
- Distinguishes between delete (empty username) and update cases
- Calls the appropriate API methods (deleteHostIdp or updateHostIdp)
- Refetches host details after success to update the UI
- Shows appropriate success/error messages
- Ensures loading state is cleared in the finally block
1218-1227: LGTM! UserCard integration correctly configured.The UserCard correctly receives:
endUsersfrom host datacanWriteEndUsercomputed from appropriate permissions (team maintainer/admin or global maintainer/admin)onClickUpdateUsercallback to show the modalThis properly enforces write permissions for IdP username management.
ghernandez345
left a comment
There was a problem hiding this comment.
Nice PR. easy to follow these changes 👍🏽
| const otherEmailsDisplayValues = generateOtherEmailsValues(endUsers); | ||
|
|
||
| const [writeButtonText, writeButtonIcon] = userNameDisplayValues.length | ||
| ? ["Edit user", "pencil" as const] |
There was a problem hiding this comment.
just curious, why the as const for the second string values?
There was a problem hiding this comment.
To narrow the type - string is not a valid type for Icon's name prop, but both "pencil" and "plus" are
noahtalerman
left a comment
There was a problem hiding this comment.
Docs (audit-logs.md) look good to me!
fc396a9 to
4f78e72
Compare
**Related issue:** Resolves #34222 [Demo](https://drive.google.com/file/d/1MyLlyUW8Qoad_3_FLwiMhMBbb8wJNwGk/view?usp=drive_link) <img width="1504" height="986" alt="Screenshot 2025-11-10 at 4 45 48 PM" src="https://github.com/user-attachments/assets/9ee80fd3-c9e7-4712-b150-11ac08c70db6" /> # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually
Related issue: Resolves #34222
Demo
Checklist for submitter
If some of the following don't apply, delete the relevant line.
changes/,Testing