Skip to content

Commit 25d9419

Browse files
authored
refactor: extract Webhook Detail editor (#268)
* refactor: extract webhook detail editor * fix: harden webhook editor validation
1 parent 604cedf commit 25d9419

19 files changed

Lines changed: 1011 additions & 305 deletions

AUDIT.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Cycles Admin Dashboard — Audit
22

3-
**Current release:** v0.1.25.80 (2026-07-20)
3+
**Current release:** v0.1.25.81 (2026-07-20)
44

55
## Baseline requirements
66

@@ -17,6 +17,48 @@
1717

1818
Newest at the top. Older entries preserved verbatim.
1919

20+
### 2026-07-20 — v0.1.25.81: Webhook Detail editor ownership
21+
22+
This follow-up completes the Webhook Detail decomposition begun in `.79` and
23+
`.80`. The security-sensitive edit protocol now lives in `useWebhookEditor`,
24+
and `WebhookEditDialog` owns its presentation; `WebhookDetailView` drops from
25+
1,268 to 1,042 lines while retaining route intent, charts, delivery controls,
26+
export presentation, and sibling-owner wiring.
27+
28+
| Owner | Responsibilities |
29+
|---|---|
30+
| `useWebhookEditor` | Independent form/baseline snapshots, diff-only PATCH construction, tenant selector boundaries, metadata/advanced validation, mutation settlement, and dialog state. |
31+
| `WebhookEditDialog` | Accessible form presentation, selector guidance, masked-header notice, and advanced-field composition. |
32+
| `WebhookDetailView` | Route intent and coordination among acquisition, operation, and editor owners. |
33+
34+
**Confirmed fixes and hardening:**
35+
36+
- A successful edit publishes the authoritative PATCH response directly. The
37+
previous PATCH-then-GET sequence could commit the mutation, lose the
38+
settlement read, leave the dialog open with an error, and invite a duplicate
39+
write. Poll reads are invalidated before PATCH and excluded until settlement.
40+
- Clearing optional strings or metadata now sends a spec-valid empty string or
41+
`{}`. The previous JSON `null` values are not part of `WebhookUpdateRequest`;
42+
the admin DTO ignored them and returned 200 while retaining the old value.
43+
A new typed request DTO prevents nullable or unknown fields from returning to
44+
the shared client boundary.
45+
- `disable_after_failures` is enforced inside the editor protocol as an integer
46+
of at least 1, with matching native `required`/`min`/`step` constraints. This
47+
closes the blank-input path where `Number('')` became 0 before submission.
48+
- Editor and non-editor operations now use reciprocal arming gates, preventing
49+
direct or future call sites from overlapping dialogs or requests. Submit and
50+
cancellation guards run inside the editor protocol, not only in the template.
51+
- The complete legacy-selector contract remains explicit and tested: untouched
52+
tenant rows omit stripped selectors, deliberate edits send both cleaned arrays
53+
(including explicit `[]` clears), and a deliberate both-empty result is
54+
refused. Diff-only fields, metadata objects, advanced replacement
55+
semantics, capability gates, route intent, and operator copy are preserved.
56+
57+
No endpoint, non-clear request shape, server/spec minimum, or server-fleet pin
58+
changes. Validation: 1,456/1,456 tests across 122 files with 97.85% line
59+
coverage; `useWebhookEditor` is 100% lines/functions and 97.70% branches.
60+
Lint, strict typecheck, production build, and both Compose validations pass.
61+
2062
### 2026-07-20 — v0.1.25.80: Webhook Detail operation ownership
2163

2264
After `.79` extracted truthful acquisition, `WebhookDetailView` still owned

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,36 @@ Dashboard versions track the governance spec (`cycles-governance-admin-v0.1.25.y
1515
end-to-end support. The fourth segment bumps independently for dashboard-only
1616
UX work that does not advance spec alignment.
1717

18+
## [0.1.25.81] — 2026-07-20
19+
20+
### Changed
21+
22+
- Webhook Detail's editor protocol and form presentation now live in focused
23+
`useWebhookEditor` and `WebhookEditDialog` owners. Route intent, charts,
24+
delivery acquisition/presentation, export, and sibling-owner coordination
25+
remain in the view.
26+
27+
### Fixed
28+
29+
- Successful edits publish the authoritative PATCH response directly instead
30+
of risking a false failure and duplicate retry when a follow-up GET fails.
31+
Poll reads are invalidated before the write and excluded through settlement.
32+
- Clearing name, description, scope, or metadata now sends the non-null empty
33+
value defined by `WebhookUpdateRequest`. Previously the admin DTO ignored the
34+
dashboard's JSON `null` and returned 200 without clearing the stored value;
35+
the client boundary is now typed to prevent invalid update fields and nulls.
36+
- Disable-after-failures now requires a whole number of at least 1 in both the
37+
form and editor protocol; clearing the field can no longer coerce to 0.
38+
- Editor and non-editor actions use reciprocal arming guards. Duplicate submit
39+
and mid-request cancellation protection now live in the editor protocol.
40+
- Tenant-owned legacy selector healing remains intact: rename-only saves omit
41+
hidden selector fields, while deliberate selector edits send both cleaned
42+
arrays (including explicit empty-array clears) and reject a both-empty result.
43+
44+
No endpoint, non-clear request shape, capability gate, server/spec minimum,
45+
route, delivery, chart, or export changes. Validation: 1,456 tests, 97.85% line
46+
coverage, lint, strict typecheck, production build, and Compose gates pass.
47+
1848
## [0.1.25.80] — 2026-07-20
1949

2050
### Changed

OPERATIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ dashboard image does not need to change for an admin-only version bump.
213213

214214
## Upgrades and rollback
215215

216-
**Pinned server fleet (dashboard v0.1.25.80):** runtime `0.1.25.58`, admin
216+
**Pinned server fleet (dashboard v0.1.25.81):** runtime `0.1.25.58`, admin
217217
`0.1.25.53`, events `0.1.25.24`. These are exact tags, not floating aliases.
218218

219219
**Upgrading an existing fleet to the pinned versions.** Upgrade events before

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ services:
370370
- cycles
371371

372372
dashboard:
373-
image: ghcr.io/runcycles/cycles-dashboard:0.1.25.80
373+
image: ghcr.io/runcycles/cycles-dashboard:0.1.25.81
374374
restart: unless-stopped
375375
healthcheck:
376376
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1/"]

docker-compose.prod.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ services:
3131

3232
dashboard:
3333
logging: *default-logging
34-
image: ghcr.io/runcycles/cycles-dashboard:0.1.25.80
34+
image: ghcr.io/runcycles/cycles-dashboard:0.1.25.81
3535
restart: unless-stopped
3636
security_opt:
3737
- no-new-privileges:true

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "cycles-dashboard",
33
"private": true,
4-
"version": "0.1.25.80",
4+
"version": "0.1.25.81",
55
"type": "module",
66
"engines": {
77
"node": ">=20.19.0"

src/__tests__/WebhookDetailView-operations.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,29 @@ describe('WebhookDetailView — operation ownership', () => {
176176
expect(wrapper.text()).not.toContain('Send Test')
177177
expect(wrapper.text()).toContain('Webhook deleted, but navigation failed: router unavailable')
178178
})
179+
180+
it('keeps editor and operation dialogs mutually exclusive in both directions', async () => {
181+
const wrapper = await mountView()
182+
183+
await clickWebhookAction(wrapper, 'Edit')
184+
expect(wrapper.find('[role="dialog"][aria-label="Edit Webhook"]').exists()).toBe(true)
185+
186+
// Even a programmatic click behind the modal cannot arm an operation while
187+
// the editor owns the surface.
188+
await clickWebhookAction(wrapper, 'Pause')
189+
expect(wrapper.text()).not.toContain('Pause this webhook?')
190+
191+
const editCancel = wrapper.findAll('button').find(button => button.text() === 'Cancel')
192+
expect(editCancel).toBeDefined()
193+
await editCancel!.trigger('click')
194+
await nextTick()
195+
196+
await clickWebhookAction(wrapper, 'Pause')
197+
expect(wrapper.text()).toContain('Pause this webhook?')
198+
199+
// The reciprocal gate refuses the editor while an operation confirmation
200+
// is armed, even if a future call site bypasses modal pointer isolation.
201+
await clickWebhookAction(wrapper, 'Edit')
202+
expect(wrapper.find('[role="dialog"][aria-label="Edit Webhook"]').exists()).toBe(false)
203+
})
179204
})

src/__tests__/WebhookDetailView-selector-clearing.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,9 @@ describe('WebhookDetailView — selector clearing (spec rev 0.1.25.39)', () => {
128128
listDeliveriesMock.mockReset()
129129
updateWebhookMock.mockReset()
130130
listDeliveriesMock.mockResolvedValue({ deliveries: [], has_more: false })
131-
updateWebhookMock.mockResolvedValue({})
131+
// PATCH returns the authoritative subscription. The editor publishes this
132+
// response directly instead of issuing a fallible settlement GET.
133+
updateWebhookMock.mockResolvedValue(subscription())
132134
routeRef.query = { action: 'edit' }
133135
document.body.innerHTML = ''
134136
})
@@ -149,6 +151,9 @@ describe('WebhookDetailView — selector clearing (spec rev 0.1.25.39)', () => {
149151
expect(body.event_types).toEqual([])
150152
// Categories untouched → diff omits them (server keeps stored value).
151153
expect('event_categories' in body).toBe(false)
154+
// Initial acquisition only: the authoritative PATCH response settles the
155+
// editor without a second GET that could falsely fail after commit.
156+
expect(getWebhookMock).toHaveBeenCalledTimes(1)
152157
expect(w.text()).not.toContain('Select at least one event type')
153158
})
154159

@@ -174,7 +179,7 @@ describe('WebhookDetailView — tenant-owned selector gating (spec revs .38/.40/
174179
listDeliveriesMock.mockReset()
175180
updateWebhookMock.mockReset()
176181
listDeliveriesMock.mockResolvedValue({ deliveries: [], has_more: false })
177-
updateWebhookMock.mockResolvedValue({})
182+
updateWebhookMock.mockResolvedValue(subscription())
178183
routeRef.query = { action: 'edit' }
179184
document.body.innerHTML = ''
180185
})
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { mount } from '@vue/test-utils'
2+
import { describe, expect, it } from 'vitest'
3+
import WebhookEditDialog from '../components/WebhookEditDialog.vue'
4+
import type { WebhookEditForm } from '../composables/useWebhookEditor'
5+
import type { WebhookSubscription } from '../types'
6+
import { emptyWebhookAdvancedForm } from '../utils/webhookAdvanced'
7+
8+
function form(): WebhookEditForm {
9+
return {
10+
name: 'Payments hook',
11+
description: 'Payment lifecycle events',
12+
url: 'https://example.test/hook',
13+
event_types: ['budget.updated'],
14+
event_categories: ['budget'],
15+
scope_filter: 'tenant:acme/*',
16+
disable_after_failures: '10',
17+
metadata: '{"team":"payments"}',
18+
}
19+
}
20+
21+
function webhook(overrides: Partial<WebhookSubscription> = {}): WebhookSubscription {
22+
return {
23+
subscription_id: 'wh-1',
24+
tenant_id: 'acme',
25+
name: 'Payments hook',
26+
url: 'https://example.test/hook',
27+
event_types: ['budget.updated'],
28+
status: 'ACTIVE',
29+
created_at: '2026-07-20T00:00:00Z',
30+
...overrides,
31+
}
32+
}
33+
34+
function mountDialog(overrides: Record<string, unknown> = {}) {
35+
return mount(WebhookEditDialog, {
36+
props: {
37+
loading: false,
38+
error: '',
39+
metadataError: '',
40+
form: form(),
41+
advanced: emptyWebhookAdvancedForm(),
42+
advancedHasConfig: false,
43+
eventTypes: ['budget.updated', 'tenant.updated'],
44+
eventCategories: ['budget', 'tenant'],
45+
tenantOwned: true,
46+
hiddenLegacySelectorCount: 0,
47+
webhook: webhook(),
48+
...overrides,
49+
},
50+
})
51+
}
52+
53+
describe('WebhookEditDialog', () => {
54+
it('renders and updates the shared form DTO, then emits submit and cancel', async () => {
55+
const sharedForm = form()
56+
const wrapper = mountDialog({ form: sharedForm })
57+
58+
expect(wrapper.get('[role="dialog"]').attributes('aria-label')).toBe('Edit Webhook')
59+
expect(wrapper.findAll('fieldset').map(group => group.get('legend').text())).toEqual([
60+
'Event types',
61+
'Event categories (additive — subscribes to all events in category, including future ones)',
62+
])
63+
expect(wrapper.get('#ew-failures').attributes()).toMatchObject({
64+
min: '1',
65+
step: '1',
66+
required: '',
67+
})
68+
await wrapper.get('#ew-name').setValue('Renamed hook')
69+
await wrapper.get('input[value="tenant.updated"]').setValue(true)
70+
await wrapper.get('form').trigger('submit')
71+
const cancel = wrapper.findAll('button').find(button => button.text() === 'Cancel')
72+
expect(cancel).toBeDefined()
73+
await cancel!.trigger('click')
74+
75+
expect(sharedForm.name).toBe('Renamed hook')
76+
expect(sharedForm.event_types).toEqual(['budget.updated', 'tenant.updated'])
77+
expect(wrapper.emitted('submit')).toHaveLength(1)
78+
expect(wrapper.emitted('cancel')).toHaveLength(1)
79+
})
80+
81+
it('exposes tenant legacy-selector and metadata validation guidance', () => {
82+
const wrapper = mountDialog({
83+
metadataError: 'Metadata must be a JSON object',
84+
hiddenLegacySelectorCount: 2,
85+
})
86+
87+
expect(wrapper.text()).toContain('Tenant-owned subscriptions can only receive tenant-scoped events')
88+
expect(wrapper.get('[data-testid="hidden-legacy-selectors-hint"]').text()).toContain(
89+
'2 legacy admin-only selectors are hidden',
90+
)
91+
const alert = wrapper.get('[role="alert"]')
92+
expect(alert.text()).toBe('Metadata must be a JSON object')
93+
})
94+
95+
it('shows masked header names and opens existing advanced configuration', () => {
96+
const advanced = emptyWebhookAdvancedForm()
97+
advanced.max_retries = '5'
98+
const wrapper = mountDialog({
99+
tenantOwned: false,
100+
webhook: webhook({ headers: { Authorization: '********', 'X-Team': '********' } }),
101+
advanced,
102+
advancedHasConfig: true,
103+
})
104+
105+
expect(wrapper.text()).toContain('Custom headers')
106+
expect(wrapper.text()).toContain('Authorization: ********')
107+
expect(wrapper.text()).toContain('X-Team: ********')
108+
expect(wrapper.get('[data-testid="ew-adv-toggle"]').attributes('aria-expanded')).toBe('true')
109+
expect((wrapper.get('#ew-adv-retries').element as HTMLInputElement).value).toBe('5')
110+
})
111+
})

0 commit comments

Comments
 (0)