Skip to content

fix(groups): accept a null filterConditions on create and stop dropping static deviceIds (#3159) - #3423

Merged
ToddHebebrand merged 2 commits into
mainfrom
fix/3159-static-group-create
Aug 11, 2026
Merged

fix(groups): accept a null filterConditions on create and stop dropping static deviceIds (#3159)#3423
ToddHebebrand merged 2 commits into
mainfrom
fix/3159-static-group-create

Conversation

@ToddHebebrand

@ToddHebebrand ToddHebebrand commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

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: null for a static group, while createGroupSchema declared the field .optional() without .nullable():

{"error":"filterConditions: Invalid input: expected object, received null"}

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.

  • The schema divergence is the bug, not the symptom. filterConditions is now ONE shared constant (groupFilterConditionsField) consumed by both createGroupSchema and updateGroupSchema, so the two cannot drift apart again without editing the constant that documents why they must match. All three forms — omitted, an object, explicit null — behave identically on both verbs.
  • null had to be accepted, not just avoided. This is a public HTTP API (mobile, MCP, partner scripts). null is 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.
  • The client still had to change, but only on create. The form now omits the key for a static create (matching CreateGroupModal, which has always done it correctly, and the documented payload). It deliberately keeps sending explicit null on edit: the update route reads undefined as "leave the filter alone" and null as "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. createGroupSchema had no deviceIds field at all, so Zod silently 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 rather than ignoring it.

Because that is a cross-tenant write, the ownership guard POST /:id/devices carried inline is extracted into validateManualMembershipDevices + addManualGroupMemberships in services/groupMembership.ts and 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 their orgId from 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 assert db.insert was never called on the rejection paths.

Two adjacent defects fixed in passing

  • The POST URL was being read out of the i18n catalog: t("deviceGroupsPage.deviceGroups"), whose value was the literal string /device-groups in 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.
  • The failure branch discarded the response body and threw a fixed "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 pattern CreateGroupModal already uses).

What I verified about the rest of #3159

  • Bug 1 (dynamic membership never persisted) is already fixed on origin/main by 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 by groups_get_create.test.ts. Not re-done here.
  • The Groups-list page crash is also already fixed: the legacy matcher now lives in deviceGroupMatching.ts, fully null-hardened, and getGroupDeviceIds no longer runs it for filter-authored groups at all — an empty-membership dynamic group can no longer reach it. deviceGroupMatching.test.ts already regression-covers undefined hostname, missing osType, 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:

  1. Group editing and drag-and-drop assignment are dead. The page sends 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.
  2. POST /device-groups/bulk does 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, .post and .windows are orphaned codemod debris in the catalogs, already unreferenced before this change, so left alone.

Tests

Suite Result
API group routes + membership service (11 files) 157 passed
DeviceGroupsPage.staticGroups.test.tsx (new, 4 cases) 4 passed
Device-groups web suites (4 files) 24 passed
i18n guards incl. locale parity (6 files) 97 passed
no-silent-mutations 100 passed
tsc --noEmit (apps/api) clean
astro check (apps/web) 0 errors
eslint (all 8 touched files) clean

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 #3159 rather than Closes — leaving it open for @cisspUser01 to verify.

🤖 Generated with Claude Code

Review round (4 agents) — outcome

/pr-review-toolkit:review-pr ran code-reviewer, pr-test-analyzer, silent-failure-hunter and comment-analyzer. 0 critical. What was raised and what I did:

Acted on:

  • Type Check failure (CI): two noUncheckedIndexedAccess errors on mock.calls[0][0] in the new create tests. Narrowed with an explicit guard rather than !/as any — fixed in c49fd86.
  • Coverage gaps (2): addManualGroupMemberships collapsing a repeated device id, and the device_group.device.add audit event the create path writes (incl. the viaGroupCreate marker). Both added in c49fd86.

Rejected, with reason:

Deferred deliberately (documented, not silently dropped):

  • No .onConflictDoNothing() on the manual membership insert, unlike evaluateGroupMembership's. The SELECT-then-INSERT race is pre-existing and only reachable on POST /:id/devices (a brand-new group id has no competing writer), and fixing it with honest added counts means reworking the insert's return handling plus several test mocks.
  • Manual adds do not write group_membership_log — only a route-level audit event. Also pre-existing on POST /:id/devices; closing it in the shared helper would change that endpoint's observable behavior, which this PR otherwise preserves exactly.
  • The web form discards invalidDevices from 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 undefined vs null semantics 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

…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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 11, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

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

View logs

…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>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Review run: /pr-review-toolkit:review-pr — code-reviewer, pr-test-analyzer, silent-failure-hunter, comment-analyzer (4 agents, parallel).

Findings: 0 critical. 3 acted on, 1 rejected on the merits, 3 deferred with reasons — all recorded in the PR body.

  • Acted: the CI Type Check failure (two noUncheckedIndexedAccess errors on mock.calls[0][0], narrowed with an explicit guard rather than !/as any), plus the two coverage gaps review found — the repeated-device-id dedupe and the device_group.device.add audit event including its viaGroupCreate marker. All in c49fd86.
  • Rejected: a request to wrap the static membership insert in try/catch "so a post-insert throw cannot strand a created group." It cannot: the handler runs inside the single baseDb.transaction opened by withDbAccessContext, so a throw rolls the group insert back and produces a retry-safe 500. A second reviewer reached the same conclusion independently and inverted the finding — the pre-existing dynamic branch's swallow is the doubtful one, and its "would invite a duplicate on retry" rationale does not hold under this transaction model. Left untouched (it predates this PR and has a test pinning its behavior); flagged as its own follow-up.
  • Deferred: the missing .onConflictDoNothing() on the manual insert, manual adds not writing group_membership_log, and the web form discarding the invalidDevices array. Each is pre-existing on POST /:id/devices and each would either change that endpoint's observable behavior or add user-facing copy across seven locales.

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), no-silent-mutations 100, tsc --noEmit clean, astro check 0 errors, eslint clean.

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 Refs #3159 rather than Closes so @cisspUser01 can verify the static-group flow first. Related: #3425 (partner-multi-org orgId query-param bug, deliberately out of scope) and #3426 (remaining API-paths-in-locale-files).

@ToddHebebrand
ToddHebebrand merged commit ea2d341 into main Aug 11, 2026
55 checks passed
@ToddHebebrand
ToddHebebrand deleted the fix/3159-static-group-create branch August 11, 2026 13:52
ToddHebebrand added a commit that referenced this pull request Aug 11, 2026
…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>
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