Fix operator-state null capabilityNames crash and stopped-container gating

Issue #1621: saving only "intentionally offline" left the SQLite
auto_remediation_policy_json column NULL, and the read path only
normalized the policy inside the non-NULL branch, so the API served
"capabilityNames": null. The section's dirty-state memo spread that
value during render and crashed the expanded row into the route error
boundary. Normalize the policy unconditionally after the scan so the
wire always carries [], type capabilityNames as nullable in the TS API
surface, and guard the spread in the component.

Issue #1622: the inline table-row presentation gated the entire
operator-overrides section on the resource exposing an auto-authorizable
capability, so stopped Docker containers (which only expose start,
never auto-authorized) lost intentionally-offline and
never-auto-remediate exactly when they matter most. Render the section
whenever the resource has an id; the automatic-actions block already
self-gates on eligible capabilities. Also add min-w-0 to the section's
flex text columns and overflow-x-clip to the inline row content box so
the long never-auto-remediate copy wraps instead of escaping the row
border below the lg breakpoint.

Regression coverage: issue-named Go test for the NULL-column read path
and a render test feeding the section the real pre-fix wire payload
with capabilityNames: null.

Fixes #1621
Fixes #1622

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
courtmanr@gmail.com 2026-07-26 11:24:59 +01:00
parent 0b9385f2f2
commit 7f4db466be
14 changed files with 215 additions and 26 deletions

View file

@ -4458,6 +4458,12 @@ normalizes the server's `404 operator_state_not_set` response into
than a thrown error; non-404 errors propagate. The PUT path than a thrown error; non-404 errors propagate. The PUT path
percent-encodes the canonical resource id segment so colon-bearing percent-encodes the canonical resource id segment so colon-bearing
ids round-trip safely through URL routing. ids round-trip safely through URL routing.
Operator-state reads must keep `autoRemediationPolicy.capabilityNames`
iterable. SQLite rows whose policy column is `NULL` normalize to an empty
capability list before JSON serialization, while the TS compatibility type and
rendering path continue to tolerate `null` from pre-fix or cached payloads.
Saving only `intentionallyOffline`, `neverAutoRemediate`, priority, or note
must therefore never produce a drawer-breaking operator-state response.
The router wires the operator-state adapter into the findings runtime The router wires the operator-state adapter into the findings runtime
at startup: `internal/api/router.go` calls at startup: `internal/api/router.go` calls

View file

@ -3688,7 +3688,10 @@ Inline detail table rows are also registry-backed. `InlineDetailTableRow`
owns the row/cell/content shell and row-click containment for platform, owns the row/cell/content shell and row-click containment for platform,
workload, and infrastructure inline drawers; callers may pass row-specific workload, and infrastructure inline drawers; callers may pass row-specific
`data-*` attributes, colspan, and content classes, but they must not recreate `data-*` attributes, colspan, and content classes, but they must not recreate
the surface-alt detail row shell locally. the surface-alt detail row shell locally. The content shell must clip
horizontal paint below the large breakpoint without becoming a scroll
container, then restore visible overflow for the static desktop layout, so
long operator-state copy cannot escape the shared row border.
Inline detail section content is registry-backed separately from the row shell. Inline detail section content is registry-backed separately from the row shell.
`DetailSectionTable`, `InlineDetailPanel`, and `detailSectionModel.ts` own `DetailSectionTable`, `InlineDetailPanel`, and `detailSectionModel.ts` own
detail row compaction, section-table rendering, value-tone classes, and the detail row compaction, section-table rendering, value-tone classes, and the

View file

@ -1790,7 +1790,11 @@ capability is never eligible, low-risk eligible, or elevated eligible; it does
not lower `MinimumApprovalLevel`. The first eligible vertical is Docker/Podman not lower `MinimumApprovalLevel`. The first eligible vertical is Docker/Podman
container `restart` at `low_risk`; unspecified capabilities normalize to never. container `restart` at `low_risk`; unspecified capabilities normalize to never.
`NormalizeResourceOperatorState` trims whitespace, de-duplicates capability `NormalizeResourceOperatorState` trims whitespace, de-duplicates capability
names, and lower-cases the criticality value before persistence. The names, guarantees a non-nil empty capability list, and lower-cases the
criticality value before persistence. SQLite reads must apply that
normalization even when `auto_remediation_policy_json` is `NULL`, so an
operator state containing only intentionally-offline or another independent
override serializes `capabilityNames: []` rather than `null`. The
`ResourceStore` interface now exposes `ResourceStore` interface now exposes
`GetResourceOperatorState`, `SetResourceOperatorState`, and `GetResourceOperatorState`, `SetResourceOperatorState`, and
`ClearResourceOperatorState`; both the SQLite (table `ClearResourceOperatorState`; both the SQLite (table
@ -1821,7 +1825,10 @@ overview tab, which routes through the canonical TS client at
`frontend-modern/src/api/resourceOperatorState.ts` to `frontend-modern/src/api/resourceOperatorState.ts` to
`/api/resources/{id}/operator-state`. Alongside the existing operator-state `/api/resources/{id}/operator-state`. Alongside the existing operator-state
controls, the drawer offers automatic actions only for backend-advertised controls, the drawer offers automatic actions only for backend-advertised
eligible capabilities. Enabling it requires selecting exact capabilities and eligible capabilities. The operator-state section itself remains available for
every canonical resource id, including stopped containers and resources with
no auto-authorizable capability; only the automatic-actions subsection is
capability-gated. Enabling it requires selecting exact capabilities and
may add a recurring daily time/timezone window; copy must state that this is a may add a recurring daily time/timezone window; copy must state that this is a
resource allowlist and that the tenant Patrol mode remains the upper bound. resource allowlist and that the tenant Patrol mode remains the upper bound.
The UI must never infer eligibility from capability names, severity, or the The UI must never infer eligibility from capability names, severity, or the

View file

@ -44,6 +44,23 @@ describe('resourceOperatorState api', () => {
await expect(getResourceOperatorState('vm:101')).resolves.toBeNull(); await expect(getResourceOperatorState('vm:101')).resolves.toBeNull();
}); });
it('preserves a legacy null capability list for compatibility consumers to normalize', async () => {
apiFetchJSONMock.mockResolvedValueOnce({
canonicalId: 'docker:host-1/ct-nginx',
intentionallyOffline: true,
neverAutoRemediate: false,
autoRemediationPolicy: {
enabled: false,
capabilityNames: null,
},
setAt: '2026-07-20T10:00:00Z',
} satisfies ResourceOperatorState);
const result = await getResourceOperatorState('docker:host-1/ct-nginx');
expect(result?.autoRemediationPolicy?.capabilityNames).toBeNull();
});
it('rethrows non-404 errors so the caller can surface them to the operator', async () => { it('rethrows non-404 errors so the caller can surface them to the operator', async () => {
apiFetchJSONMock.mockRejectedValueOnce( apiFetchJSONMock.mockRejectedValueOnce(
Object.assign(new Error('Internal server error'), { status: 500 }), Object.assign(new Error('Internal server error'), { status: 500 }),

View file

@ -17,7 +17,12 @@ export interface AutoRemediationWindow {
export interface AutoRemediationPolicy { export interface AutoRemediationPolicy {
enabled: boolean; enabled: boolean;
capabilityNames: string[]; /**
* Nullable on the wire: rows persisted before the store normalized the
* policy on read carry `"capabilityNames": null` (issue #1621). Treat
* null the same as [].
*/
capabilityNames: string[] | null;
window?: AutoRemediationWindow; window?: AutoRemediationWindow;
} }

View file

@ -355,11 +355,6 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
drawer.resourceTimelineCount() > 0 || drawer.resourceTimelineCount() > 0 ||
Boolean(drawer.facetBundleError()); Boolean(drawer.facetBundleError());
const shouldRenderOperationalGovernanceSections = () => !compactTableRow(); const shouldRenderOperationalGovernanceSections = () => !compactTableRow();
const shouldRenderOperatorStateSection = () =>
!compactTableRow() ||
resource.capabilities?.some(
(capability) => capability.autoAuthorization && capability.autoAuthorization !== 'never',
);
const shouldRenderActionHistorySection = () => const shouldRenderActionHistorySection = () =>
drawer.actionAuditAvailable() && drawer.actionAuditAvailable() &&
(!compactTableRow() || (!compactTableRow() ||
@ -697,8 +692,13 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
never auto-remediate) sits next to the action history so never auto-remediate) sits next to the action history so
the "what overrides has the operator set" and "what actions the "what overrides has the operator set" and "what actions
has Pulse taken" stories read together. The section is has Pulse taken" stories read together. The section is
self-fetching keyed on the canonical resource id. */} self-fetching keyed on the canonical resource id. Rendered
<Show when={shouldRenderOperatorStateSection() && resource.id}> for every resource with an id — the operator overrides
(intentionally offline, never auto-remediate, note, priority)
don't depend on capabilities; only the automatic-actions
block does, and it gates itself inside the section
(issue #1622). */}
<Show when={resource.id}>
<ResourceOperatorStateSection <ResourceOperatorStateSection
resourceId={resource.id} resourceId={resource.id}
capabilities={resource.capabilities} capabilities={resource.capabilities}

View file

@ -139,7 +139,9 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
capabilityNames: [], capabilityNames: [],
}; };
const selected = [...autoCapabilities()].sort(); const selected = [...autoCapabilities()].sort();
const persistedSelected = [...persistedAuto.capabilityNames].sort(); // capabilityNames can arrive as null from rows persisted before the
// policy column was always written (issue #1621) — never assume [].
const persistedSelected = [...(persistedAuto.capabilityNames ?? [])].sort();
const windowChanged = autoWindowEnabled() const windowChanged = autoWindowEnabled()
? !persistedAuto.window || ? !persistedAuto.window ||
persistedAuto.window.timezone !== autoWindowTimezone() || persistedAuto.window.timezone !== autoWindowTimezone() ||
@ -536,7 +538,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
= +1h) or with the persisted window when one exists. */} = +1h) or with the persisted window when one exists. */}
<Show when={!schedulerOpen()}> <Show when={!schedulerOpen()}>
<div class="flex items-center justify-between gap-3 pt-2 border-t border-border-subtle"> <div class="flex items-center justify-between gap-3 pt-2 border-t border-border-subtle">
<div class="flex-1"> <div class="min-w-0 flex-1">
<label class="text-sm font-medium text-base-content">Maintenance window</label> <label class="text-sm font-medium text-base-content">Maintenance window</label>
<p class="text-[11px] text-muted mt-0.5 leading-tight"> <p class="text-[11px] text-muted mt-0.5 leading-tight">
Suspend findings on this resource for a defined window. Useful for scheduled upgrades, Suspend findings on this resource for a defined window. Useful for scheduled upgrades,
@ -673,7 +675,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
<Show when={eligibleAutoCapabilities().length > 0}> <Show when={eligibleAutoCapabilities().length > 0}>
<div class="space-y-3 border-t border-border-subtle pt-3" aria-label="Automatic actions"> <div class="space-y-3 border-t border-border-subtle pt-3" aria-label="Automatic actions">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="flex-1"> <div class="min-w-0 flex-1">
<label class="text-sm font-medium text-base-content">Automatic actions</label> <label class="text-sm font-medium text-base-content">Automatic actions</label>
<p class="mt-0.5 text-[11px] leading-tight text-muted"> <p class="mt-0.5 text-[11px] leading-tight text-muted">
Choose exactly which actions Patrol may run on this resource. Your Patrol mode, live Choose exactly which actions Patrol may run on this resource. Your Patrol mode, live
@ -780,7 +782,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
</Show> </Show>
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="flex-1"> <div class="min-w-0 flex-1">
<label class="text-sm font-medium text-base-content">Intentionally offline</label> <label class="text-sm font-medium text-base-content">Intentionally offline</label>
<p class="text-[11px] text-muted mt-0.5 leading-tight"> <p class="text-[11px] text-muted mt-0.5 leading-tight">
Suppress findings on this resource. Use when a workload is deprecated, a dev environment Suppress findings on this resource. Use when a workload is deprecated, a dev environment
@ -795,7 +797,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
</div> </div>
<div class="flex items-start justify-between gap-3 pt-2 border-t border-border-subtle"> <div class="flex items-start justify-between gap-3 pt-2 border-t border-border-subtle">
<div class="flex-1"> <div class="min-w-0 flex-1">
<label class="text-sm font-medium text-red-700 dark:text-red-400"> <label class="text-sm font-medium text-red-700 dark:text-red-400">
Never auto-remediate Never auto-remediate
</label> </label>

View file

@ -366,7 +366,10 @@ describe('ResourceDetailDrawer change history section', () => {
expect(screen.getByText('Current state')).toBeInTheDocument(); expect(screen.getByText('Current state')).toBeInTheDocument();
expect(screen.getByText('Identity')).toBeInTheDocument(); expect(screen.getByText('Identity')).toBeInTheDocument();
expect(screen.queryByText('Change history')).not.toBeInTheDocument(); expect(screen.queryByText('Change history')).not.toBeInTheDocument();
expect(screen.queryByText('Operator overrides')).not.toBeInTheDocument(); // Operator overrides render in every presentation (issue #1622) —
// intentionally-offline and never-auto-remediate must stay reachable
// even for resources with no auto-authorizable capabilities.
expect(screen.getByText('Operator overrides')).toBeInTheDocument();
expect(screen.queryByText('Maintenance verification')).not.toBeInTheDocument(); expect(screen.queryByText('Maintenance verification')).not.toBeInTheDocument();
expect(screen.queryByText('Action history')).not.toBeInTheDocument(); expect(screen.queryByText('Action history')).not.toBeInTheDocument();
expect(screen.queryByText('Refreshing changes...')).not.toBeInTheDocument(); expect(screen.queryByText('Refreshing changes...')).not.toBeInTheDocument();

View file

@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@solidjs/testing-library';
import type { ResourceCapability } from '@/types/resource';
const apiClientMock = vi.hoisted(() => ({
apiFetchJSON: vi.fn(),
}));
vi.mock('@/utils/apiClient', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/apiClient')>();
return {
...actual,
apiFetchJSON: apiClientMock.apiFetchJSON,
};
});
import { ResourceOperatorStateSection } from '@/components/Infrastructure/ResourceOperatorStateSection';
// Regression for issue #1621: rows saved with only "intentionally offline"
// left the auto_remediation_policy_json column NULL, and the pre-fix read
// path skipped normalization for NULL columns, so the wire carried
// "capabilityNames": null. The section's dirty-state memo spread that value
// during render, throwing "null is not iterable" straight into the route
// error boundary. These tests feed the section the real pre-fix wire
// payload through the canonical API client to pin the null tolerance.
const OPERATOR_STATE_WITH_NULL_CAPABILITIES = JSON.parse(
`{
"canonicalId": "docker:host-1/ct-nginx",
"intentionallyOffline": true,
"neverAutoRemediate": false,
"autoRemediationPolicy": {"enabled": false, "capabilityNames": null},
"setAt": "2026-07-20T10:00:00Z",
"setBy": "admin"
}`,
) as unknown;
const RESTART_CAPABILITY: ResourceCapability[] = [
{
name: 'restart',
type: 'docker',
description: 'Restart the container',
autoAuthorization: 'low_risk',
},
];
describe('ResourceOperatorStateSection render with capabilityNames: null', () => {
it('renders the persisted record without crashing and reports a clean (not dirty) state', async () => {
apiClientMock.apiFetchJSON.mockResolvedValue(OPERATOR_STATE_WITH_NULL_CAPABILITIES);
render(() => (
<ResourceOperatorStateSection
resourceId="docker:host-1/ct-nginx"
capabilities={RESTART_CAPABILITY}
/>
));
// The section itself must be up (pre-fix, the throw escaped render
// and nothing below the header survived).
expect(screen.getByText('Operator overrides')).toBeTruthy();
expect(screen.getByText('Intentionally offline')).toBeTruthy();
expect(screen.getByText('Never auto-remediate')).toBeTruthy();
// "Clear all overrides" only renders for persisted() && !isDirty() —
// reaching it proves the dirty-state memo evaluated the null
// capabilityNames without throwing and treated it as [].
await waitFor(() => {
expect(screen.getByText('Clear all overrides')).toBeTruthy();
});
expect(screen.getByText(/Set by admin/)).toBeTruthy();
expect(screen.queryByText('Save overrides')).toBeNull();
});
it('hydrates a null capability allowlist as an empty selection in the automatic-actions block', async () => {
apiClientMock.apiFetchJSON.mockResolvedValue(OPERATOR_STATE_WITH_NULL_CAPABILITIES);
render(() => (
<ResourceOperatorStateSection
resourceId="docker:host-1/ct-nginx-2"
capabilities={RESTART_CAPABILITY}
/>
));
await waitFor(() => {
expect(screen.getByText('Clear all overrides')).toBeTruthy();
});
// The automatic-actions block renders (the resource has an eligible
// capability) with its toggle off and no phantom selection derived
// from the null allowlist.
expect(screen.getByText('Automatic actions')).toBeTruthy();
expect(screen.queryByText('Allowed actions')).toBeNull();
});
});

View file

@ -197,12 +197,16 @@ describe('ResourceDetailDrawerOverviewTab integration', () => {
expect(operatorIndex).toBeLessThan(historyIndex); expect(operatorIndex).toBeLessThan(historyIndex);
}); });
it('keeps eligible automatic actions reachable from compact platform rows', () => { it('renders operator overrides for every resource regardless of capability eligibility', () => {
expect(overviewTabSource).toContain('shouldRenderOperatorStateSection'); // Issue #1622: the section used to be gated on the resource exposing
expect(overviewTabSource).toContain('capability.autoAuthorization'); // an auto-authorizable capability, which hid intentionally-offline /
expect(overviewTabSource).toContain("capability.autoAuthorization !== 'never'"); // never-auto-remediate exactly when they matter most (a stopped
expect(overviewTabSource).toContain( // container only exposes `start`, which is never auto-authorized).
'<Show when={shouldRenderOperatorStateSection() && resource.id}>', // The overrides don't depend on capabilities — only the
); // automatic-actions block does, and it self-gates inside the section
// via eligibleAutoCapabilities.
expect(overviewTabSource).toContain('<Show when={resource.id}>');
expect(overviewTabSource).not.toContain('shouldRenderOperatorStateSection');
expect(sectionSource).toContain('<Show when={eligibleAutoCapabilities().length > 0}>');
}); });
}); });

View file

@ -3,8 +3,11 @@ import { splitProps, type JSX } from 'solid-js';
import { TableCell, TableRow, type TableRowProps } from './Table'; import { TableCell, TableRow, type TableRowProps } from './Table';
export const INLINE_DETAIL_TABLE_CELL_CLASS = 'p-0 border-b border-border bg-surface-alt'; export const INLINE_DETAIL_TABLE_CELL_CLASS = 'p-0 border-b border-border bg-surface-alt';
// overflow-x-clip keeps over-wide content from painting outside the row's
// border below the lg breakpoint (issue #1622) without creating a scroll
// container the way overflow-hidden would.
export const INLINE_DETAIL_TABLE_CONTENT_CLASS = export const INLINE_DETAIL_TABLE_CONTENT_CLASS =
'sticky left-0 max-w-[calc(100vw-3.5rem)] px-2 py-3 sm:px-4 sm:py-4 lg:static lg:max-w-none'; 'sticky left-0 max-w-[calc(100vw-3.5rem)] overflow-x-clip px-2 py-3 sm:px-4 sm:py-4 lg:static lg:max-w-none lg:overflow-x-visible';
export interface InlineDetailTableRowProps extends TableRowProps { export interface InlineDetailTableRowProps extends TableRowProps {
cellId?: string; cellId?: string;

View file

@ -7408,6 +7408,8 @@ describe('shared primitive guardrails', () => {
expect(registeredRule?.canonical?.export).toBe('InlineDetailTableRow'); expect(registeredRule?.canonical?.export).toBe('InlineDetailTableRow');
expect(inlineDetailTableRowSource).toContain('INLINE_DETAIL_TABLE_CELL_CLASS'); expect(inlineDetailTableRowSource).toContain('INLINE_DETAIL_TABLE_CELL_CLASS');
expect(inlineDetailTableRowSource).toContain('INLINE_DETAIL_TABLE_CONTENT_CLASS'); expect(inlineDetailTableRowSource).toContain('INLINE_DETAIL_TABLE_CONTENT_CLASS');
expect(inlineDetailTableRowSource).toContain('overflow-x-clip');
expect(inlineDetailTableRowSource).toContain('lg:overflow-x-visible');
expect(inlineDetailTableRowSource).toContain('event.stopPropagation()'); expect(inlineDetailTableRowSource).toContain('event.stopPropagation()');
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual( expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual(
expect.arrayContaining([ expect.arrayContaining([

View file

@ -1,6 +1,8 @@
package unifiedresources package unifiedresources
import ( import (
"encoding/json"
"strings"
"testing" "testing"
"time" "time"
) )
@ -84,3 +86,41 @@ func TestAutoRemediationPolicySQLiteRoundTrip(t *testing.T) {
t.Fatalf("round-trip policy = %#v", got.AutoRemediationPolicy) t.Fatalf("round-trip policy = %#v", got.AutoRemediationPolicy)
} }
} }
func TestSQLiteOperatorStateNullPolicyColumnNormalizesOnRead(t *testing.T) {
store, err := NewSQLiteResourceStore(t.TempDir(), defaultOrgID)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer store.Close()
saved := ResourceOperatorState{
CanonicalID: "docker:host-1/ct-nginx",
IntentionallyOffline: true,
SetAt: time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC),
SetBy: "operator:richard",
}
if err := store.SetResourceOperatorState(saved); err != nil {
t.Fatalf("set: %v", err)
}
got, found, err := store.GetResourceOperatorState(saved.CanonicalID)
if err != nil {
t.Fatalf("get: %v", err)
}
if !found {
t.Fatal("expected entry after set")
}
if got.AutoRemediationPolicy.CapabilityNames == nil {
t.Fatal("CapabilityNames must be normalized when the policy column is NULL")
}
encoded, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(encoded), `"capabilityNames":null`) ||
!strings.Contains(string(encoded), `"capabilityNames":[]`) {
t.Fatalf("wire payload must contain an empty capabilityNames list, got: %s", encoded)
}
}

View file

@ -2931,8 +2931,11 @@ func scanResourceOperatorState(scanner resourceOperatorStateScanner) (ResourceOp
if err := json.Unmarshal([]byte(autoPolicyJSON.String), &state.AutoRemediationPolicy); err != nil { if err := json.Unmarshal([]byte(autoPolicyJSON.String), &state.AutoRemediationPolicy); err != nil {
return ResourceOperatorState{}, fmt.Errorf("unmarshal auto remediation policy: %w", err) return ResourceOperatorState{}, fmt.Errorf("unmarshal auto remediation policy: %w", err)
} }
state.AutoRemediationPolicy = NormalizeAutoRemediationPolicy(state.AutoRemediationPolicy)
} }
// Normalize even when the column was NULL (row saved with an empty
// policy) so CapabilityNames is never nil and the wire carries [],
// not null (issue #1621).
state.AutoRemediationPolicy = NormalizeAutoRemediationPolicy(state.AutoRemediationPolicy)
if startAt.Valid { if startAt.Valid {
t := startAt.Time t := startAt.Time
state.MaintenanceStartAt = &t state.MaintenanceStartAt = &t