@open-mercato/webhooks 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4559.1.839e136509
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js +238 -0
- package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js +46 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js +92 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js +61 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js +76 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js +81 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js +69 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js +64 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/helpers/fixtures.js +50 -1
- package/dist/modules/webhooks/__integration__/helpers/fixtures.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/[id]/route.js +24 -0
- package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
- package/dist/modules/webhooks/backend/webhooks/[id]/page.js +9 -2
- package/dist/modules/webhooks/backend/webhooks/[id]/page.js.map +3 -3
- package/dist/modules/webhooks/backend/webhooks/page.js +4 -1
- package/dist/modules/webhooks/backend/webhooks/page.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.ts +352 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.ts +63 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.ts +126 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.ts +82 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.ts +102 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.ts +109 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.ts +95 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.ts +89 -0
- package/src/modules/webhooks/__integration__/helpers/fixtures.ts +101 -1
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/optimistic-lock.test.ts +101 -0
- package/src/modules/webhooks/api/webhooks/[id]/route.ts +26 -0
- package/src/modules/webhooks/backend/webhooks/[id]/page.tsx +9 -2
- package/src/modules/webhooks/backend/webhooks/page.tsx +4 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { test, expect, type APIRequestContext } from '@playwright/test'
|
|
2
|
+
import { getAuthToken, apiRequest } from '@open-mercato/core/modules/core/__integration__/helpers/api'
|
|
3
|
+
import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'
|
|
4
|
+
import {
|
|
5
|
+
putWithLock,
|
|
6
|
+
expectConflictBody,
|
|
7
|
+
expectConflictBanner,
|
|
8
|
+
resolveApiUrl,
|
|
9
|
+
} from '@open-mercato/core/modules/core/__integration__/helpers/optimisticLockUi'
|
|
10
|
+
import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* TC-LOCK-OSS-043 — webhooks + inbox settings + data-sync schedule
|
|
14
|
+
* optimistic-lock conflict contract (WHK-01, INB-01, SYNC-01).
|
|
15
|
+
*
|
|
16
|
+
* All three surfaces guard writes with `enforceCommandOptimisticLock` inside
|
|
17
|
+
* pure command/API routes — none expose a dedicated `data-crud-field-id`
|
|
18
|
+
* CrudForm edit page for the locked record, so the conflict contract is proven
|
|
19
|
+
* at the API level: capture the record's `updated_at`, advance it out-of-band
|
|
20
|
+
* via a header-less write (the strictly-additive path always succeeds and bumps
|
|
21
|
+
* `updated_at`), then replay the now-stale write with the original
|
|
22
|
+
* expected-version header → 409 `optimistic_lock_conflict`.
|
|
23
|
+
*
|
|
24
|
+
* - WHK-01: `PUT /api/webhooks/<id>` and `DELETE /api/webhooks/<id>`
|
|
25
|
+
* (`packages/webhooks/src/modules/webhooks/api/webhooks/[id]/route.ts`,
|
|
26
|
+
* `resourceKind: 'webhooks.endpoint'`).
|
|
27
|
+
* - INB-01: `PATCH /api/inbox_ops/settings`
|
|
28
|
+
* (`packages/core/src/modules/inbox_ops/api/settings/route.ts`,
|
|
29
|
+
* `resourceKind: 'inbox_ops.settings'`). The settings row is a per-tenant
|
|
30
|
+
* singleton, so the test bumps + restores `workingLanguage` instead of
|
|
31
|
+
* creating/deleting a fixture.
|
|
32
|
+
* - SYNC-01: `POST /api/data_sync/schedules`
|
|
33
|
+
* (`packages/core/src/modules/data_sync/api/schedules/route.ts` →
|
|
34
|
+
* `SyncScheduleService.saveSchedule`, `resourceKind: 'data_sync.schedule'`).
|
|
35
|
+
* The collection POST reads the expected-version header; the service enforces
|
|
36
|
+
* the lock when a row with the same (integrationId, entityType, direction)
|
|
37
|
+
* key already exists.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const WEBHOOKS_API = '/api/webhooks'
|
|
41
|
+
const INBOX_SETTINGS_API = '/api/inbox_ops/settings'
|
|
42
|
+
const SCHEDULES_API = '/api/data_sync/schedules'
|
|
43
|
+
|
|
44
|
+
type InboxSettings = { id: string; workingLanguage: string; updatedAt: string }
|
|
45
|
+
|
|
46
|
+
async function createWebhook(
|
|
47
|
+
request: APIRequestContext,
|
|
48
|
+
token: string,
|
|
49
|
+
stamp: number,
|
|
50
|
+
): Promise<{ id: string; updatedAt: string }> {
|
|
51
|
+
const created = await apiRequest(request, 'POST', WEBHOOKS_API, {
|
|
52
|
+
token,
|
|
53
|
+
data: {
|
|
54
|
+
name: `QA Lock 043 ${stamp}`,
|
|
55
|
+
url: `https://example.com/qa-lock-043-${stamp}`,
|
|
56
|
+
subscribedEvents: ['qa.lock.test'],
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
expect(created.status(), 'POST webhook should be 201').toBe(201)
|
|
60
|
+
const body = (await created.json()) as { id?: string }
|
|
61
|
+
expect(typeof body.id, 'webhook creation should return an id').toBe('string')
|
|
62
|
+
const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${body.id}`, { token })
|
|
63
|
+
expect(detail.status(), 'GET webhook detail should be 200').toBe(200)
|
|
64
|
+
const detailBody = (await detail.json()) as { updatedAt?: string }
|
|
65
|
+
expect(typeof detailBody.updatedAt, 'webhook should expose updatedAt').toBe('string')
|
|
66
|
+
return { id: body.id as string, updatedAt: detailBody.updatedAt as string }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readWebhookUpdatedAt(
|
|
70
|
+
request: APIRequestContext,
|
|
71
|
+
token: string,
|
|
72
|
+
id: string,
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${id}`, { token })
|
|
75
|
+
expect(detail.status(), 'GET webhook detail should be 200').toBe(200)
|
|
76
|
+
const body = (await detail.json()) as { updatedAt?: string }
|
|
77
|
+
return body.updatedAt as string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function deleteWebhook(
|
|
81
|
+
request: APIRequestContext,
|
|
82
|
+
token: string,
|
|
83
|
+
id: string,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
const current = await readWebhookUpdatedAt(request, token, id).catch(() => undefined)
|
|
86
|
+
await request
|
|
87
|
+
.fetch(resolveApiUrl(`${WEBHOOKS_API}/${id}`), {
|
|
88
|
+
method: 'DELETE',
|
|
89
|
+
headers: {
|
|
90
|
+
Authorization: `Bearer ${token}`,
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
...(current ? { [OPTIMISTIC_LOCK_HEADER_NAME]: current } : {}),
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
.catch(() => undefined)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function patchInboxSettings(
|
|
99
|
+
request: APIRequestContext,
|
|
100
|
+
token: string,
|
|
101
|
+
body: Record<string, unknown>,
|
|
102
|
+
lockValue?: string,
|
|
103
|
+
) {
|
|
104
|
+
return request.fetch(resolveApiUrl(INBOX_SETTINGS_API), {
|
|
105
|
+
method: 'PATCH',
|
|
106
|
+
headers: {
|
|
107
|
+
Authorization: `Bearer ${token}`,
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),
|
|
110
|
+
},
|
|
111
|
+
data: body,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readInboxSettings(
|
|
116
|
+
request: APIRequestContext,
|
|
117
|
+
token: string,
|
|
118
|
+
): Promise<InboxSettings> {
|
|
119
|
+
const response = await apiRequest(request, 'GET', INBOX_SETTINGS_API, { token })
|
|
120
|
+
expect(response.status(), 'GET inbox settings should be 200').toBe(200)
|
|
121
|
+
const body = (await response.json()) as { settings?: InboxSettings | null }
|
|
122
|
+
expect(body.settings, 'tenant inbox settings singleton should exist').toBeTruthy()
|
|
123
|
+
return body.settings as InboxSettings
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function postSchedule(
|
|
127
|
+
request: APIRequestContext,
|
|
128
|
+
token: string,
|
|
129
|
+
scheduleValue: string,
|
|
130
|
+
integrationId: string,
|
|
131
|
+
lockValue?: string,
|
|
132
|
+
) {
|
|
133
|
+
return request.fetch(resolveApiUrl(SCHEDULES_API), {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
Authorization: `Bearer ${token}`,
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),
|
|
139
|
+
},
|
|
140
|
+
data: {
|
|
141
|
+
integrationId,
|
|
142
|
+
entityType: 'products',
|
|
143
|
+
direction: 'import',
|
|
144
|
+
scheduleType: 'cron',
|
|
145
|
+
scheduleValue,
|
|
146
|
+
timezone: 'UTC',
|
|
147
|
+
fullSync: false,
|
|
148
|
+
isEnabled: true,
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
test.describe('TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule optimistic lock', () => {
|
|
154
|
+
test('WHK-01 stale webhook PUT is refused with a 409 conflict', async ({ page }) => {
|
|
155
|
+
const token = await getAuthToken(page.request, 'admin')
|
|
156
|
+
const stamp = Date.now()
|
|
157
|
+
let webhookId: string | null = null
|
|
158
|
+
try {
|
|
159
|
+
const webhook = await createWebhook(page.request, token, stamp)
|
|
160
|
+
webhookId = webhook.id
|
|
161
|
+
const staleUpdatedAt = webhook.updatedAt
|
|
162
|
+
|
|
163
|
+
// Advance updated_at out-of-band via a header-less PUT.
|
|
164
|
+
const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {
|
|
165
|
+
token,
|
|
166
|
+
data: { name: `QA Lock 043 bumped ${stamp}` },
|
|
167
|
+
})
|
|
168
|
+
expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)
|
|
169
|
+
|
|
170
|
+
// Replay the now-stale write → 409.
|
|
171
|
+
const conflict = await putWithLock(
|
|
172
|
+
page.request,
|
|
173
|
+
token,
|
|
174
|
+
`${WEBHOOKS_API}/${webhookId}`,
|
|
175
|
+
{ name: `QA Lock 043 stale ${stamp}` },
|
|
176
|
+
staleUpdatedAt,
|
|
177
|
+
)
|
|
178
|
+
await expectConflictBody(conflict)
|
|
179
|
+
} finally {
|
|
180
|
+
if (webhookId) await deleteWebhook(page.request, token, webhookId)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('WHK-01 stale webhook DELETE is refused with a 409 conflict', async ({ page }) => {
|
|
185
|
+
const token = await getAuthToken(page.request, 'admin')
|
|
186
|
+
const stamp = Date.now()
|
|
187
|
+
let webhookId: string | null = null
|
|
188
|
+
try {
|
|
189
|
+
const webhook = await createWebhook(page.request, token, stamp)
|
|
190
|
+
webhookId = webhook.id
|
|
191
|
+
const staleUpdatedAt = webhook.updatedAt
|
|
192
|
+
|
|
193
|
+
const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {
|
|
194
|
+
token,
|
|
195
|
+
data: { name: `QA Lock 043 bumped ${stamp}` },
|
|
196
|
+
})
|
|
197
|
+
expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)
|
|
198
|
+
|
|
199
|
+
const conflict = await page.request.fetch(resolveApiUrl(`${WEBHOOKS_API}/${webhookId}`), {
|
|
200
|
+
method: 'DELETE',
|
|
201
|
+
headers: {
|
|
202
|
+
Authorization: `Bearer ${token}`,
|
|
203
|
+
'Content-Type': 'application/json',
|
|
204
|
+
[OPTIMISTIC_LOCK_HEADER_NAME]: staleUpdatedAt,
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
await expectConflictBody(conflict)
|
|
208
|
+
} finally {
|
|
209
|
+
if (webhookId) await deleteWebhook(page.request, token, webhookId)
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// WHK-01 (browser UI): now ACTIVE. The product fix is committed/verified — the webhooks
|
|
214
|
+
// server DELETE returns the structured 409 (proven by the active "WHK-01 stale webhook
|
|
215
|
+
// DELETE is refused with a 409" API test) and the list page sends the lock header
|
|
216
|
+
// (`buildOptimisticLockHeader(row.updatedAt)`) + routes the resulting 409 through
|
|
217
|
+
// surfaceRecordConflict. The RowActions menu opens on CLICK of the "Open actions" kebab and
|
|
218
|
+
// renders the items in a portal on document.body (role="menuitem"); the confirm dialog is a
|
|
219
|
+
// role="alertdialog" with a "Confirm" button. We drive that choreography robustly here:
|
|
220
|
+
// wait for the filtered list GET to settle, open the kebab, click Delete, confirm, then
|
|
221
|
+
// assert the unified conflict bar (never the success toast).
|
|
222
|
+
test('WHK-01 stale webhook DELETE in the list surfaces the conflict bar', async ({ page }) => {
|
|
223
|
+
const token = await getAuthToken(page.request, 'admin')
|
|
224
|
+
const stamp = Date.now()
|
|
225
|
+
const webhookName = `QA Lock 043 ${stamp}`
|
|
226
|
+
let webhookId: string | null = null
|
|
227
|
+
try {
|
|
228
|
+
const webhook = await createWebhook(page.request, token, stamp)
|
|
229
|
+
webhookId = webhook.id
|
|
230
|
+
|
|
231
|
+
await login(page, 'admin')
|
|
232
|
+
await page.goto('/backend/webhooks')
|
|
233
|
+
// Wait for the list GET to settle before interacting. The list reflects the
|
|
234
|
+
// freshly created fixture immediately (newest-first), so a unique name makes
|
|
235
|
+
// the row unambiguous.
|
|
236
|
+
await page
|
|
237
|
+
.waitForResponse(
|
|
238
|
+
(response) =>
|
|
239
|
+
response.url().includes('/api/webhooks') &&
|
|
240
|
+
response.request().method() === 'GET' &&
|
|
241
|
+
response.ok(),
|
|
242
|
+
{ timeout: 20_000 },
|
|
243
|
+
)
|
|
244
|
+
.catch(() => undefined)
|
|
245
|
+
|
|
246
|
+
const row = page.getByRole('row', { name: new RegExp(`${webhookName}\\b`) }).first()
|
|
247
|
+
await expect(row, 'created webhook should appear in the list').toBeVisible({ timeout: 20_000 })
|
|
248
|
+
|
|
249
|
+
// Advance updated_at out-of-band → the in-page row token is now stale.
|
|
250
|
+
const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {
|
|
251
|
+
token,
|
|
252
|
+
data: { name: `QA Lock 043 bumped ${stamp}` },
|
|
253
|
+
})
|
|
254
|
+
expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)
|
|
255
|
+
|
|
256
|
+
// Open the row's RowActions kebab (opens on click) and trigger Delete. The
|
|
257
|
+
// menu renders in a portal on document.body, so query it at the page level.
|
|
258
|
+
const kebab = row.getByRole('button', { name: /open actions/i })
|
|
259
|
+
await expect(kebab, 'row should expose an Open actions trigger').toBeVisible()
|
|
260
|
+
await kebab.click()
|
|
261
|
+
const deleteItem = page.getByRole('menuitem', { name: /^delete$/i })
|
|
262
|
+
await expect(deleteItem, 'delete menu item should be visible').toBeVisible({ timeout: 10_000 })
|
|
263
|
+
await deleteItem.click()
|
|
264
|
+
|
|
265
|
+
// Confirm the destructive alertdialog (the row still holds the stale
|
|
266
|
+
// updated_at captured at render time, so the DELETE 409s).
|
|
267
|
+
const dialog = page.getByRole('alertdialog')
|
|
268
|
+
await expect(dialog, 'confirm dialog should open').toBeVisible({ timeout: 10_000 })
|
|
269
|
+
await dialog.getByRole('button', { name: /^(confirm|delete)$/i }).click()
|
|
270
|
+
|
|
271
|
+
// The client must route the 409 to the unified conflict bar, never the
|
|
272
|
+
// success toast.
|
|
273
|
+
await expectConflictBanner(page)
|
|
274
|
+
} finally {
|
|
275
|
+
if (webhookId) await deleteWebhook(page.request, token, webhookId)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('INB-01 stale inbox-settings PATCH is refused with a 409 conflict', async ({ page }) => {
|
|
280
|
+
const token = await getAuthToken(page.request, 'admin')
|
|
281
|
+
const before = await readInboxSettings(page.request, token)
|
|
282
|
+
const originalLanguage = before.workingLanguage
|
|
283
|
+
const staleUpdatedAt = before.updatedAt
|
|
284
|
+
const bumpLanguage = originalLanguage === 'de' ? 'en' : 'de'
|
|
285
|
+
const staleLanguage = originalLanguage === 'es' ? 'pl' : 'es'
|
|
286
|
+
try {
|
|
287
|
+
// Advance updated_at out-of-band via a header-less PATCH.
|
|
288
|
+
const bump = await patchInboxSettings(page.request, token, { workingLanguage: bumpLanguage })
|
|
289
|
+
expect(bump.status(), 'out-of-band inbox PATCH should succeed').toBeLessThan(300)
|
|
290
|
+
|
|
291
|
+
// Replay the now-stale write with the original expected-version → 409.
|
|
292
|
+
const conflict = await patchInboxSettings(
|
|
293
|
+
page.request,
|
|
294
|
+
token,
|
|
295
|
+
{ workingLanguage: staleLanguage },
|
|
296
|
+
staleUpdatedAt,
|
|
297
|
+
)
|
|
298
|
+
await expectConflictBody(conflict)
|
|
299
|
+
} finally {
|
|
300
|
+
// Restore the original working language with a fresh lock token.
|
|
301
|
+
const current = await readInboxSettings(page.request, token).catch(() => null)
|
|
302
|
+
if (current) {
|
|
303
|
+
await patchInboxSettings(
|
|
304
|
+
page.request,
|
|
305
|
+
token,
|
|
306
|
+
{ workingLanguage: originalLanguage },
|
|
307
|
+
current.updatedAt,
|
|
308
|
+
).catch(() => undefined)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('SYNC-01 stale data-sync schedule save is refused with a 409 conflict', async ({ page }) => {
|
|
314
|
+
const token = await getAuthToken(page.request, 'admin')
|
|
315
|
+
const stamp = Date.now()
|
|
316
|
+
const integrationId = `qa-lock-043-${stamp}`
|
|
317
|
+
let scheduleId: string | null = null
|
|
318
|
+
try {
|
|
319
|
+
const created = await postSchedule(page.request, token, '0 * * * *', integrationId)
|
|
320
|
+
expect(created.status(), 'POST schedule should be 201').toBe(201)
|
|
321
|
+
const createdBody = (await created.json()) as { id?: string; updatedAt?: string }
|
|
322
|
+
scheduleId = createdBody.id ?? null
|
|
323
|
+
const staleUpdatedAt = createdBody.updatedAt
|
|
324
|
+
expect(typeof scheduleId, 'schedule creation should return an id').toBe('string')
|
|
325
|
+
expect(typeof staleUpdatedAt, 'schedule should expose updatedAt').toBe('string')
|
|
326
|
+
|
|
327
|
+
// Advance updated_at out-of-band via the header-less [id] PUT route.
|
|
328
|
+
const bump = await apiRequest(page.request, 'PUT', `${SCHEDULES_API}/${scheduleId}`, {
|
|
329
|
+
token,
|
|
330
|
+
data: { scheduleValue: '5 * * * *' },
|
|
331
|
+
})
|
|
332
|
+
expect(bump.status(), 'out-of-band schedule PUT should succeed').toBeLessThan(300)
|
|
333
|
+
|
|
334
|
+
// Replay the save for the same (integrationId, entityType, direction) key
|
|
335
|
+
// with the now-stale expected-version → 409.
|
|
336
|
+
const conflict = await postSchedule(
|
|
337
|
+
page.request,
|
|
338
|
+
token,
|
|
339
|
+
'9 * * * *',
|
|
340
|
+
integrationId,
|
|
341
|
+
staleUpdatedAt as string,
|
|
342
|
+
)
|
|
343
|
+
await expectConflictBody(conflict)
|
|
344
|
+
} finally {
|
|
345
|
+
if (scheduleId) {
|
|
346
|
+
await apiRequest(page.request, 'DELETE', `${SCHEDULES_API}/${scheduleId}`, { token }).catch(
|
|
347
|
+
() => undefined,
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test';
|
|
2
|
+
import { getAuthToken } from '@open-mercato/core/helpers/integration/api';
|
|
3
|
+
import {
|
|
4
|
+
createWebhookFixture,
|
|
5
|
+
deleteWebhookIfExists,
|
|
6
|
+
getWebhookDetail,
|
|
7
|
+
rotateWebhookSecret,
|
|
8
|
+
} from './helpers/fixtures';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* TC-WEBHOOK-004: Secret rotation with previous secret retention
|
|
12
|
+
* Source: https://github.com/open-mercato/open-mercato/issues/2482
|
|
13
|
+
*
|
|
14
|
+
* Verifies the security-critical rotate-secret flow: a new whsec_ secret is issued,
|
|
15
|
+
* the previous secret is retained (so deliveries can dual-sign during rollout), and
|
|
16
|
+
* the detail endpoint reflects the masked new secret plus the rotation timestamp.
|
|
17
|
+
*/
|
|
18
|
+
test.describe('TC-WEBHOOK-004: Secret rotation with previous secret retention', () => {
|
|
19
|
+
test('should rotate the signing secret, retain the previous one, and mask it on read', async ({ request }) => {
|
|
20
|
+
const token = await getAuthToken(request);
|
|
21
|
+
let webhookId: string | null = null;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const created = await createWebhookFixture(request, token, {
|
|
25
|
+
name: `Webhook Rotate ${Date.now()}`,
|
|
26
|
+
url: 'https://example.com/webhook-rotate',
|
|
27
|
+
subscribedEvents: ['sales.quote.created'],
|
|
28
|
+
});
|
|
29
|
+
webhookId = created.id;
|
|
30
|
+
expect(created.secret.startsWith('whsec_')).toBe(true);
|
|
31
|
+
|
|
32
|
+
const beforeRotation = await getWebhookDetail(request, token, created.id);
|
|
33
|
+
expect(beforeRotation.previousSecretSetAt).toBeNull();
|
|
34
|
+
|
|
35
|
+
const rotation = await rotateWebhookSecret(request, token, created.id);
|
|
36
|
+
expect(rotation.success).toBe(true);
|
|
37
|
+
expect(rotation.secret.startsWith('whsec_')).toBe(true);
|
|
38
|
+
expect(rotation.secret).not.toBe(created.secret);
|
|
39
|
+
expect(typeof rotation.previousSecretSetAt).toBe('string');
|
|
40
|
+
expect(Number.isNaN(Date.parse(rotation.previousSecretSetAt as string))).toBe(false);
|
|
41
|
+
|
|
42
|
+
const afterRotation = await getWebhookDetail(request, token, created.id);
|
|
43
|
+
// The detail endpoint never exposes the raw secret — only a masked form that
|
|
44
|
+
// reveals the constant "whsec_" prefix (maskSecret => `${secret.slice(0, 6)}••••••••`).
|
|
45
|
+
expect(afterRotation.maskedSecret.startsWith('whsec_')).toBe(true);
|
|
46
|
+
expect(afterRotation.maskedSecret).not.toBe(rotation.secret);
|
|
47
|
+
expect(afterRotation.maskedSecret).not.toBe(created.secret);
|
|
48
|
+
expect(afterRotation.maskedSecret.length).toBeLessThan(rotation.secret.length);
|
|
49
|
+
// previousSecretSetAt proves the prior secret is retained for dual-sign verification
|
|
50
|
+
// (delivery signing reads webhook.previousSecret; covered at unit level in lib/__tests__).
|
|
51
|
+
expect(afterRotation.previousSecretSetAt).toBe(rotation.previousSecretSetAt);
|
|
52
|
+
|
|
53
|
+
const secondRotation = await rotateWebhookSecret(request, token, created.id);
|
|
54
|
+
expect(secondRotation.secret).not.toBe(rotation.secret);
|
|
55
|
+
expect(typeof secondRotation.previousSecretSetAt).toBe('string');
|
|
56
|
+
expect(Date.parse(secondRotation.previousSecretSetAt as string)).toBeGreaterThanOrEqual(
|
|
57
|
+
Date.parse(rotation.previousSecretSetAt as string),
|
|
58
|
+
);
|
|
59
|
+
} finally {
|
|
60
|
+
await deleteWebhookIfExists(request, token, webhookId);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test';
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';
|
|
3
|
+
import { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures';
|
|
4
|
+
import {
|
|
5
|
+
cleanupWebhooksUser,
|
|
6
|
+
createWebhookFixture,
|
|
7
|
+
deleteWebhookIfExists,
|
|
8
|
+
provisionWebhooksUser,
|
|
9
|
+
type ProvisionedWebhooksUser,
|
|
10
|
+
} from './helpers/fixtures';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* TC-WEBHOOK-005: RBAC permission gates — unauthorized access by feature flag
|
|
14
|
+
* Source: https://github.com/open-mercato/open-mercato/issues/2482
|
|
15
|
+
*
|
|
16
|
+
* Verifies every webhooks API surface is gated by its declared feature:
|
|
17
|
+
* webhooks.view → GET list + GET detail + deliveries
|
|
18
|
+
* webhooks.manage → POST + PUT + DELETE
|
|
19
|
+
* webhooks.secrets → POST rotate-secret
|
|
20
|
+
* webhooks.test → POST test
|
|
21
|
+
* A viewer holding only webhooks.view passes reads but is denied every write/secret/test
|
|
22
|
+
* surface; a user holding no webhooks features is denied reads. Admin (webhooks.*) proves
|
|
23
|
+
* the positive paths. Denials accept 401 or 403 per the issue's acceptance criteria.
|
|
24
|
+
*/
|
|
25
|
+
test.describe('TC-WEBHOOK-005: RBAC permission gates by feature flag', () => {
|
|
26
|
+
test('should gate each webhooks surface behind its declared feature', async ({ request }) => {
|
|
27
|
+
const adminToken = await getAuthToken(request);
|
|
28
|
+
const scope = getTokenScope(adminToken);
|
|
29
|
+
|
|
30
|
+
let webhookId: string | null = null;
|
|
31
|
+
let viewer: ProvisionedWebhooksUser | null = null;
|
|
32
|
+
let noAccess: ProvisionedWebhooksUser | null = null;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
viewer = await provisionWebhooksUser(request, adminToken, {
|
|
36
|
+
tenantId: scope.tenantId,
|
|
37
|
+
organizationId: scope.organizationId,
|
|
38
|
+
features: ['webhooks.view'],
|
|
39
|
+
slug: 'viewer',
|
|
40
|
+
});
|
|
41
|
+
noAccess = await provisionWebhooksUser(request, adminToken, {
|
|
42
|
+
tenantId: scope.tenantId,
|
|
43
|
+
organizationId: scope.organizationId,
|
|
44
|
+
features: [],
|
|
45
|
+
slug: 'noaccess',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// --- Positive: admin (webhooks.*) can exercise every surface ---
|
|
49
|
+
const created = await createWebhookFixture(request, adminToken, {
|
|
50
|
+
name: `Webhook RBAC ${Date.now()}`,
|
|
51
|
+
url: 'https://example.com/webhook-rbac',
|
|
52
|
+
subscribedEvents: ['catalog.product.created'],
|
|
53
|
+
customHeaders: { 'x-mock-webhook-signature': 'valid' },
|
|
54
|
+
});
|
|
55
|
+
webhookId = created.id;
|
|
56
|
+
|
|
57
|
+
const adminList = await apiRequest(request, 'GET', '/api/webhooks', { token: adminToken });
|
|
58
|
+
expect(adminList.status()).toBe(200);
|
|
59
|
+
const adminDetail = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: adminToken });
|
|
60
|
+
expect(adminDetail.status()).toBe(200);
|
|
61
|
+
|
|
62
|
+
// --- webhooks.view gate ---
|
|
63
|
+
const viewerList = await apiRequest(request, 'GET', '/api/webhooks', { token: viewer.token });
|
|
64
|
+
expect(viewerList.status()).toBe(200);
|
|
65
|
+
const viewerDetail = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: viewer.token });
|
|
66
|
+
expect(viewerDetail.status()).toBe(200);
|
|
67
|
+
|
|
68
|
+
const noAccessList = await apiRequest(request, 'GET', '/api/webhooks', { token: noAccess.token });
|
|
69
|
+
expect([401, 403]).toContain(noAccessList.status());
|
|
70
|
+
const noAccessDetail = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: noAccess.token });
|
|
71
|
+
expect([401, 403]).toContain(noAccessDetail.status());
|
|
72
|
+
|
|
73
|
+
// --- webhooks.manage gate (viewer lacks manage) ---
|
|
74
|
+
const viewerCreate = await apiRequest(request, 'POST', '/api/webhooks', {
|
|
75
|
+
token: viewer.token,
|
|
76
|
+
data: {
|
|
77
|
+
name: `Webhook RBAC Denied ${Date.now()}`,
|
|
78
|
+
url: 'https://example.com/webhook-denied',
|
|
79
|
+
subscribedEvents: ['catalog.product.created'],
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
expect([401, 403]).toContain(viewerCreate.status());
|
|
83
|
+
|
|
84
|
+
const viewerUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
|
|
85
|
+
token: viewer.token,
|
|
86
|
+
data: { name: `Webhook RBAC Hijack ${Date.now()}` },
|
|
87
|
+
});
|
|
88
|
+
expect([401, 403]).toContain(viewerUpdate.status());
|
|
89
|
+
|
|
90
|
+
const viewerDelete = await apiRequest(request, 'DELETE', `/api/webhooks/${created.id}`, { token: viewer.token });
|
|
91
|
+
expect([401, 403]).toContain(viewerDelete.status());
|
|
92
|
+
|
|
93
|
+
// --- webhooks.secrets gate (viewer lacks secrets) ---
|
|
94
|
+
const viewerRotate = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/rotate-secret`, {
|
|
95
|
+
token: viewer.token,
|
|
96
|
+
});
|
|
97
|
+
expect([401, 403]).toContain(viewerRotate.status());
|
|
98
|
+
|
|
99
|
+
// --- webhooks.test gate (viewer lacks test) ---
|
|
100
|
+
const viewerTest = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/test`, {
|
|
101
|
+
token: viewer.token,
|
|
102
|
+
data: { eventType: 'catalog.product.created' },
|
|
103
|
+
});
|
|
104
|
+
expect([401, 403]).toContain(viewerTest.status());
|
|
105
|
+
|
|
106
|
+
// The webhook must still exist — none of the denied writes mutated it.
|
|
107
|
+
const stillThere = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: adminToken });
|
|
108
|
+
expect(stillThere.status()).toBe(200);
|
|
109
|
+
|
|
110
|
+
// --- Positive secrets + test paths (admin holds both features) ---
|
|
111
|
+
const adminRotate = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/rotate-secret`, {
|
|
112
|
+
token: adminToken,
|
|
113
|
+
});
|
|
114
|
+
expect(adminRotate.status()).toBe(200);
|
|
115
|
+
const adminTest = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/test`, {
|
|
116
|
+
token: adminToken,
|
|
117
|
+
data: { eventType: 'catalog.product.created' },
|
|
118
|
+
});
|
|
119
|
+
expect(adminTest.status()).toBe(200);
|
|
120
|
+
} finally {
|
|
121
|
+
await deleteWebhookIfExists(request, adminToken, webhookId);
|
|
122
|
+
await cleanupWebhooksUser(request, adminToken, viewer);
|
|
123
|
+
await cleanupWebhooksUser(request, adminToken, noAccess);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test';
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';
|
|
3
|
+
import {
|
|
4
|
+
buildMockInboundUrl,
|
|
5
|
+
createWebhookFixture,
|
|
6
|
+
deleteWebhookIfExists,
|
|
7
|
+
listWebhookDeliveries,
|
|
8
|
+
sendTestDelivery,
|
|
9
|
+
} from './helpers/fixtures';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* TC-WEBHOOK-006: Webhook deactivation blocks delivery
|
|
13
|
+
* Source: https://github.com/open-mercato/open-mercato/issues/2482
|
|
14
|
+
*
|
|
15
|
+
* The isActive flag must prevent delivery. The synchronous test route still records a
|
|
16
|
+
* delivery attempt, but the delivery engine short-circuits inactive webhooks: the
|
|
17
|
+
* delivery is marked `expired` with "Webhook is inactive" and no HTTP request is made
|
|
18
|
+
* (responseStatus stays null). Re-activating restores successful delivery.
|
|
19
|
+
*/
|
|
20
|
+
test.describe('TC-WEBHOOK-006: Webhook deactivation blocks delivery', () => {
|
|
21
|
+
test('should deliver while active, block while inactive, and resume after re-activation', async ({ request }) => {
|
|
22
|
+
const token = await getAuthToken(request);
|
|
23
|
+
let webhookId: string | null = null;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const created = await createWebhookFixture(request, token, {
|
|
27
|
+
name: `Webhook Deactivation ${Date.now()}`,
|
|
28
|
+
url: buildMockInboundUrl(),
|
|
29
|
+
subscribedEvents: ['catalog.product.created'],
|
|
30
|
+
customHeaders: { 'x-mock-webhook-signature': 'valid' },
|
|
31
|
+
});
|
|
32
|
+
webhookId = created.id;
|
|
33
|
+
|
|
34
|
+
// 1) Active webhook delivers successfully.
|
|
35
|
+
const activeDelivery = await sendTestDelivery(request, token, created.id, {
|
|
36
|
+
eventType: 'catalog.product.created',
|
|
37
|
+
});
|
|
38
|
+
expect(activeDelivery.delivery.status).toBe('delivered');
|
|
39
|
+
expect(activeDelivery.delivery.responseStatus).toBe(200);
|
|
40
|
+
|
|
41
|
+
// 2) Deactivate.
|
|
42
|
+
const deactivate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
|
|
43
|
+
token,
|
|
44
|
+
data: { isActive: false },
|
|
45
|
+
});
|
|
46
|
+
expect(deactivate.status()).toBe(200);
|
|
47
|
+
|
|
48
|
+
// 3) Inactive webhook does not reach the endpoint — delivery is expired, no HTTP attempt.
|
|
49
|
+
const blockedDelivery = await sendTestDelivery(request, token, created.id, {
|
|
50
|
+
eventType: 'catalog.product.created',
|
|
51
|
+
});
|
|
52
|
+
expect(blockedDelivery.delivery.status).toBe('expired');
|
|
53
|
+
expect(blockedDelivery.delivery.errorMessage).toBe('Webhook is inactive');
|
|
54
|
+
expect(blockedDelivery.delivery.responseStatus).toBeNull();
|
|
55
|
+
|
|
56
|
+
// The blocked attempt is visible in the delivery log with a non-delivered status.
|
|
57
|
+
const expiredDeliveries = await listWebhookDeliveries(
|
|
58
|
+
request,
|
|
59
|
+
token,
|
|
60
|
+
created.id,
|
|
61
|
+
`/api/webhooks/deliveries?webhookId=${encodeURIComponent(created.id)}&status=expired`,
|
|
62
|
+
);
|
|
63
|
+
expect(expiredDeliveries.items.some((item) => item.id === blockedDelivery.delivery.id)).toBe(true);
|
|
64
|
+
expect(expiredDeliveries.items.every((item) => item.status === 'expired')).toBe(true);
|
|
65
|
+
|
|
66
|
+
// 4) Re-activate and confirm delivery resumes.
|
|
67
|
+
const reactivate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
|
|
68
|
+
token,
|
|
69
|
+
data: { isActive: true },
|
|
70
|
+
});
|
|
71
|
+
expect(reactivate.status()).toBe(200);
|
|
72
|
+
|
|
73
|
+
const resumedDelivery = await sendTestDelivery(request, token, created.id, {
|
|
74
|
+
eventType: 'catalog.product.created',
|
|
75
|
+
});
|
|
76
|
+
expect(resumedDelivery.delivery.status).toBe('delivered');
|
|
77
|
+
expect(resumedDelivery.delivery.responseStatus).toBe(200);
|
|
78
|
+
} finally {
|
|
79
|
+
await deleteWebhookIfExists(request, token, webhookId);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|