fix(groups): accept a null filterConditions on create and stop dropping static deviceIds (#3159) - #3423
Conversation
…ng static deviceIds (#3159) Static device-group creation from the dashboard failed with a 400 every single time. The create form posts one payload for both verbs, carrying `filterConditions: null` for a static group, and `createGroupSchema` declared the field `.optional()` without `.nullable()` — so every attempt died on "Invalid input: expected object, received null". The update schema sitting three lines below it had always been `.nullable().optional()`, which is why editing a group worked and creating one never did. The divergence is the defect, so the field is now ONE shared constant used by both schemas; they cannot drift apart again without editing the thing that documents why. `null` is not merely tolerated: the update route reads `undefined` as "leave the filter alone" and `null` as "clear it", which is how a dynamic group becomes static. Fixing only that would have traded a loud 400 for a silent empty group: `createGroupSchema` had no `deviceIds` field either, so Zod stripped the devices the user had just hand-picked and returned 201 on an empty group. Create now accepts `deviceIds` and materializes the membership in the same request — and rejects a non-empty list on a dynamic group instead of ignoring it. Because that is a cross-tenant write, the device-ownership guard that `POST /:id/devices` carried inline is extracted to `validateManualMembershipDevices` + `addManualGroupMemberships` and shared by both routes, so there is one implementation rather than two that can drift. The batch is validated BEFORE the group row is inserted: a `c.json(..., 400)` is a normal response, not an exception, so a late rejection would commit a stranded empty group. Web side: - The create payload omits `filterConditions` (matching CreateGroupModal and the documented shape) while the edit payload still sends explicit `null`, since that is the only way to clear a stored filter. - The POST URL was being read out of the i18n catalog (`t("deviceGroupsPage.deviceGroups")`, value "/device-groups" in all seven locales) — a codemod accident that would have broken group creation the moment a translator touched that entry. Restored to a literal and the key removed from the catalogs. - The failure branch threw a fixed string and discarded the response body, which is why the reporter had to read the 400 out of devtools. It now surfaces the server's message. Tests: 8 route cases for the accepted/rejected create shapes (including that a rejected batch inserts no group), 9 unit cases for the extracted helpers, and 4 web cases pinning the create/edit payloads, the literal URL, and the surfaced error. Three sibling test files mocked the membership service and are updated to pass the real helpers through, so their existing cross-org and site-confinement assertions keep exercising the extracted code. Refs #3159 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
c49fd86
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://8618d421.breeze-9te.pages.dev |
| Branch Preview URL: | https://fix-3159-static-group-create.breeze-9te.pages.dev |
…udit event Fixes the two `noUncheckedIndexedAccess` errors CI's Type Check caught in the new create tests: `mock.calls[0][0]` needs the call itself narrowed first. Destructured with an explicit guard rather than `!` or `as any` — a test whose whole job is to prove a payload shape should not assert its way past the type system to do it. Adds the two cases review flagged as genuinely uncovered: - `addManualGroupMemberships` collapsing a repeated device id, so a duplicate in the payload cannot reach the `(device_id, group_id)` primary key as a constraint violation. - The `device_group.device.add` audit event the create path writes, including the `viaGroupCreate` marker that distinguishes it from a later manual add. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review run: Findings: 0 critical. 3 acted on, 1 rejected on the merits, 3 deferred with reasons — all recorded in the PR body.
Tests: all 54 checks pass, 1 skipping — Test API, Test Web, Type Check, Lint and all four Integration Tests shards green. Locally: 157 API tests across 11 group-related files, 24 device-groups web tests, 97 i18n guards (incl. locale parity, since 7 catalogs changed), Of 21 new cases, 17 fail against unpatched code (14 API + 3 web), verified by reverting the implementation; the other 4 are baseline non-regression checks and are commented as such. Status: review-clean, CI green, awaiting maintainer merge. Left as |
…3424) ## What The Configuration Policies list header rendered **`0of0policies`**. ## Root cause The #2340 extraction codemod split the sentence `"N of M policies"` into four adjacent JSX expression containers in `ConfigPolicyList.tsx`: ```tsx {filteredPolicies.length} {i18n.t("policies:configurationPolicies.configPolicyList.of")} {policies.length} {i18n.t("policies:configurationPolicies.configPolicyList.policies")} ``` JSX inserts no whitespace between expression containers, so all four values concatenate. ## Fix Collapsed into a single interpolated key. Adding literal spaces would have fixed the spacing while leaving the word order untranslatable, which is the wrong shape for a sentence that has to work in seven locales. **No translation is invented.** Each locale's `summary` is composed from that locale's own existing `of` and `policies` fragments: | Locale | summary | |---|---| | en | `{{filtered}} of {{total}} policies` | | de-DE | `{{filtered}} von {{total}} Richtlinien` | | es-419 | `{{filtered}} de {{total}} políticas` | | fr-CA / fr-FR | `{{filtered}} de {{total}} politiques` | | it-IT | `{{filtered}} di {{total}} criteri` | | pt-BR | `{{filtered}} de {{total}} políticas` | The two French entries are lowercased to `politiques` — the fragment was capitalized because it was extracted from a standalone heading, and a French common noun is not capitalized mid-sentence. That is the only wording change. The now-unused `of` and `policies` keys are **deliberately left in place**: they are inert, and removing keys risks tripping a locale parity gate that a spacing fix has no business touching. ## Verification - `tsc --noEmit` on `@breeze/web` — clean. - `vitest run ConfigPolicyList.test.tsx DeviceGroupsPage.test.tsx DeviceGroupsPage.dynamicGroups.test.tsx i18n.test.ts` — **39/39 pass**. ## Provenance Found during v0.105.0 release QA (internal Playwright UI sweep, 2026-08-11). Evidence logged in `docs/testing/FEATURE_TEST_LOG.md`. **Related, not included:** the same codemod left six API route paths and one filesystem path sitting in the locale files as translatable values (`/device-groups`, `/configuration-policies` ×2, `/scripts?limit=200`, `/alerts/channels?limit=200`, `/software/catalog?limit=100`, `/etc/ssh/sshd_config`). Only one had a live call site and #3423 is already fixing that one; the rest are dead keys. Filed separately rather than widened into this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Todd Hebebrand <todd@lanternops.io> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What was broken
Creating a static device group from the dashboard failed with a 400 on every attempt, reported in #3159 by @cisspUser01 against 0.103.0.
The create form builds one payload for both verbs and sent
filterConditions: nullfor a static group, whilecreateGroupSchemadeclared the field.optional()without.nullable():The update schema three lines below it had always been
.nullable().optional(). That asymmetry is why editing a group worked and creating one never could.Why this fix, rather than just loosening the schema
Both sides, for different reasons — and the divergence itself is the defect.
filterConditionsis now ONE shared constant (groupFilterConditionsField) consumed by bothcreateGroupSchemaandupdateGroupSchema, so the two cannot drift apart again without editing the constant that documents why they must match. All three forms — omitted, an object, explicitnull— behave identically on both verbs.nullhad to be accepted, not just avoided. This is a public HTTP API (mobile, MCP, partner scripts).nullis a legitimate way to say "no filter", the create route already normalized it (payload.filterConditions ?? null), and rejecting it only on create was arbitrary. Widening is backward compatible.CreateGroupModal, which has always done it correctly, and the documented payload). It deliberately keeps sending explicitnullon edit: the update route readsundefinedas "leave the filter alone" andnullas "clear it", so omitting it there would strand a stale filter on a group the user just converted from dynamic to static.The silent failure hiding behind the 400
Fixing only the null would have traded a loud 400 for something worse.
createGroupSchemahad nodeviceIdsfield at all, so Zod silently stripped the devices the user had just hand-picked and returned 201 on an empty group.Create now accepts
deviceIdsand materializes the membership in the same request, and rejects a non-empty list on a dynamic group rather than ignoring it.Because that is a cross-tenant write, the ownership guard
POST /:id/devicescarried inline is extracted intovalidateManualMembershipDevices+addManualGroupMembershipsinservices/groupMembership.tsand shared by both routes — one implementation of the tenancy check instead of two that can drift. It enforces org ownership against the group's org, the site boundary for a site-bound group, and dedupe; membership rows take theirorgIdfrom the group, never from the request.Ordering matters: the batch is validated before the group row is inserted. A
return c.json(..., 400)is a normal response, not an exception, so the request transaction still commits — validating late would leave a stranded empty group behind on every rejected batch. Three tests assertdb.insertwas never called on the rejection paths.Two adjacent defects fixed in passing
t("deviceGroupsPage.deviceGroups"), whose value was the literal string/device-groupsin all seven locales. A codemod accident that worked only for as long as no translator touched that entry. Restored to a literal and the key removed from the catalogs."Failed to save device group", which is why the reporter had to dig the real 400 out of devtools. It now surfaces the server's message (the patternCreateGroupModalalready uses).What I verified about the rest of #3159
origin/mainby fix(api): materialize dynamic group membership instead of stranding it on a dead transaction #3181 — the evaluation is awaited at both call sites with the transaction-race explained in-place, and covered bygroups_get_create.test.ts. Not re-done here.deviceGroupMatching.ts, fully null-hardened, andgetGroupDeviceIdsno longer runs it for filter-authored groups at all — an empty-membership dynamic group can no longer reach it.deviceGroupMatching.test.tsalready regression-covers undefined hostname, missingosType, and malformed rules. Nothing left to harden.Known adjacent bugs NOT in this PR (deliberately)
Found while tracing this page; both are separate live defects and bundling them would bury a security-sensitive membership change:
PUT /device-groups/:id, but the router only defines.patch('/:id')— no PUT handler exists anywhere, so Hono falls through to the 404 handler. Needs a focused PR with update-transition tests.POST /device-groups/bulkdoes not exist, so the bulk script/policy actions on that page 404 too. That one needs an API design decision, not a patch.Also noted:
deviceGroupsPage.put,.postand.windowsare orphaned codemod debris in the catalogs, already unreferenced before this change, so left alone.Tests
DeviceGroupsPage.staticGroups.test.tsx(new, 4 cases)no-silent-mutationstsc --noEmit(apps/api)astro check(apps/web)New coverage: 8 route cases for the accepted/rejected create shapes, 9 unit cases for the extracted helpers, 4 web cases pinning the create/edit payloads, the literal URL and the surfaced error. Verified non-vacuous by reverting the implementation — 14 API tests and 3 of 4 web tests fail against the unpatched code.
Three sibling test files mocked the membership service and would have 500'd after the extraction; they now pass the real helpers through, so their pre-existing cross-org and site-confinement assertions keep exercising the shared code rather than a mock.
Refs #3159rather thanCloses— leaving it open for @cisspUser01 to verify.🤖 Generated with Claude Code
Review round (4 agents) — outcome
/pr-review-toolkit:review-prran code-reviewer, pr-test-analyzer, silent-failure-hunter and comment-analyzer. 0 critical. What was raised and what I did:Acted on:
noUncheckedIndexedAccesserrors onmock.calls[0][0]in the new create tests. Narrowed with an explicit guard rather than!/as any— fixed inc49fd86.addManualGroupMembershipscollapsing a repeated device id, and thedevice_group.device.addaudit event the create path writes (incl. theviaGroupCreatemarker). Both added inc49fd86.Rejected, with reason:
try/catcharound the static membership insert, mirroring the dynamic branch, arguing a post-insert throw would strand a created group. It would not — the whole handler runs inside onebaseDb.transactionopened bywithDbAccessContext, so a throw rolls the group insert back and yields a retry-safe 500 with Sentry context. silent-failure-hunter independently reached the same conclusion and pointed the finding the other way: the dynamic branch's swallow is the questionable one, and the pre-existing comment's "would invite a duplicate on retry" rationale does not hold under this transaction model. That branch predates this PR and has a test pinning its 201-and-log behavior, so I left it alone rather than change behavior outside [Device Groups] Two bugs: (1) dynamic group membership never persists after creation, causing the Groups list page to crash; (2) static group creation always fails — frontend sends filterConditions: null, which the backend schema rejects #3159. Worth its own look.Deferred deliberately (documented, not silently dropped):
.onConflictDoNothing()on the manual membership insert, unlikeevaluateGroupMembership's. The SELECT-then-INSERT race is pre-existing and only reachable onPOST /:id/devices(a brand-new group id has no competing writer), and fixing it with honestaddedcounts means reworking the insert's return handling plus several test mocks.group_membership_log— only a route-level audit event. Also pre-existing onPOST /:id/devices; closing it in the shared helper would change that endpoint's observable behavior, which this PR otherwise preserves exactly.invalidDevicesfrom a 400, so the user sees the failure class but not which device caused it. Rendering it needs new user-facing copy across seven locales, which is more than this fix should carry.Verified accurate by comment-analyzer: the transaction-commit claim behind the validate-before-insert ordering, the
undefinedvsnullsemantics on PATCH, and the shared-helper claim were each traced to the implementing lines.On test counts, precisely: 21 new cases, of which 17 fail against unpatched code (14 API + 3 web). The other 4 are baseline non-regression checks (omitted key, object form, empty array, and the edit-path
null) that passed before too — they document the contract rather than the fix, and are commented as such.Related issues
orgId is required when partner has multiple organizations: the route readsorgIdfrom the body only while the web client sends it as a query param. Reproduces only with multiple orgs, which is why [Device Groups] Two bugs: (1) dynamic group membership never persists after creation, causing the Groups list page to crash; (2) static group creation always fails — frontend sends filterConditions: null, which the backend schema rejects #3159's reporter never hit it. Deliberately not fixed here — scoped to [UI][API] Device group creation 400s for every multi-org partner — create route reads orgId from the body only, web sends it as a query param #3425.