create-mercato-app 0.5.1-develop.2663.2c29774b5b → 0.5.1-develop.2681.c559bb2bc3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mercato-app",
3
- "version": "0.5.1-develop.2663.2c29774b5b",
3
+ "version": "0.5.1-develop.2681.c559bb2bc3",
4
4
  "type": "module",
5
5
  "description": "Create a new Open Mercato application",
6
6
  "main": "./dist/index.js",
@@ -6,7 +6,6 @@ import {
6
6
  createRuntimeNoiseFilter,
7
7
  isStatelessRuntimeNoiseLine,
8
8
  } from './dev-runtime-log-policy.mjs'
9
- import { resolveSpawnCommand } from './dev-spawn-utils.mjs'
10
9
 
11
10
  function resolveSplashHelpersImport() {
12
11
  const candidates = [
@@ -23,6 +22,21 @@ function resolveSplashHelpersImport() {
23
22
  throw new Error('Unable to resolve dev splash helpers module')
24
23
  }
25
24
 
25
+ function resolveSpawnUtilsImport() {
26
+ const candidates = [
27
+ new URL('./dev-spawn-utils.mjs', import.meta.url),
28
+ new URL('../../../scripts/dev-spawn-utils.mjs', import.meta.url),
29
+ ]
30
+
31
+ for (const candidate of candidates) {
32
+ if (fs.existsSync(fileURLToPath(candidate))) {
33
+ return candidate.href
34
+ }
35
+ }
36
+
37
+ throw new Error('Unable to resolve dev spawn utils module')
38
+ }
39
+
26
40
  function isEnabledEnvFlag(value) {
27
41
  if (typeof value !== 'string') return false
28
42
  return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase())
@@ -41,6 +55,7 @@ const {
41
55
  stripAnsi,
42
56
  wrapListLines,
43
57
  } = await import(resolveSplashHelpersImport())
58
+ const { resolveSpawnCommand } = await import(resolveSpawnUtilsImport())
44
59
 
45
60
  const command = process.platform === 'win32' ? 'mercato.cmd' : 'mercato'
46
61
  const classic = process.argv.includes('--classic') || isEnabledEnvFlag(process.env.OM_DEV_CLASSIC)
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import '@testing-library/jest-dom'
6
+ import { act, fireEvent, render, screen } from '@testing-library/react'
7
+
8
+ jest.mock('../global-error-reload', () => ({
9
+ reloadPage: jest.fn(),
10
+ }))
11
+
12
+ import GlobalError, { isNetworkError } from '../global-error'
13
+ import { reloadPage } from '../global-error-reload'
14
+
15
+ const reloadMock = reloadPage as jest.MockedFunction<typeof reloadPage>
16
+
17
+ describe('isNetworkError', () => {
18
+ it('returns false for nullish input', () => {
19
+ expect(isNetworkError(null)).toBe(false)
20
+ expect(isNetworkError(undefined)).toBe(false)
21
+ })
22
+
23
+ it('detects ChunkLoadError by name', () => {
24
+ const err = Object.assign(new Error('whatever'), { name: 'ChunkLoadError' })
25
+ expect(isNetworkError(err)).toBe(true)
26
+ })
27
+
28
+ it('detects "Loading chunk" messages', () => {
29
+ expect(isNetworkError(new Error('Loading chunk 42 failed'))).toBe(true)
30
+ expect(isNetworkError(new Error('Loading CSS chunk 7 failed'))).toBe(true)
31
+ })
32
+
33
+ it('detects fetch/network failures', () => {
34
+ expect(isNetworkError(new Error('Failed to fetch'))).toBe(true)
35
+ expect(isNetworkError(new Error('NetworkError when attempting to fetch resource'))).toBe(true)
36
+ expect(isNetworkError(new Error('Network request failed'))).toBe(true)
37
+ })
38
+
39
+ it('detects browser network error codes', () => {
40
+ expect(isNetworkError({ code: 'ERR_INTERNET_DISCONNECTED' })).toBe(true)
41
+ expect(isNetworkError({ message: 'net::ERR_NETWORK_CHANGED' })).toBe(true)
42
+ })
43
+
44
+ it('returns false for unrelated application errors', () => {
45
+ expect(isNetworkError(new Error('Cannot read properties of undefined'))).toBe(false)
46
+ expect(isNetworkError(new Error('Validation failed'))).toBe(false)
47
+ })
48
+ })
49
+
50
+ describe('GlobalError component', () => {
51
+ const originalOnLine = Object.getOwnPropertyDescriptor(window.navigator, 'onLine')
52
+
53
+ function setOnLine(value: boolean) {
54
+ Object.defineProperty(window.navigator, 'onLine', {
55
+ configurable: true,
56
+ get: () => value,
57
+ })
58
+ }
59
+
60
+ beforeEach(() => {
61
+ reloadMock.mockClear()
62
+ })
63
+
64
+ afterEach(() => {
65
+ if (originalOnLine) {
66
+ Object.defineProperty(window.navigator, 'onLine', originalOnLine)
67
+ }
68
+ })
69
+
70
+ it('renders generic crash UI for non-network errors', () => {
71
+ setOnLine(true)
72
+ const reset = jest.fn()
73
+ const err = Object.assign(new Error('boom'), { name: 'TypeError' })
74
+ render(<GlobalError error={err} reset={reset} />)
75
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Something went wrong')
76
+ expect(screen.getByText(/unexpected error occurred/i)).toBeInTheDocument()
77
+ fireEvent.click(screen.getByRole('button', { name: /try again/i }))
78
+ expect(reset).toHaveBeenCalledTimes(1)
79
+ expect(reloadMock).not.toHaveBeenCalled()
80
+ })
81
+
82
+ it('renders offline UI for ChunkLoadError even when navigator reports online', () => {
83
+ setOnLine(true)
84
+ const reset = jest.fn()
85
+ const err = Object.assign(new Error('Loading chunk 3 failed'), { name: 'ChunkLoadError' })
86
+ render(<GlobalError error={err} reset={reset} />)
87
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/offline/i)
88
+ expect(screen.getByText(/check your internet connection/i)).toBeInTheDocument()
89
+ expect(screen.getByRole('button', { name: /retry now/i })).toBeInTheDocument()
90
+ })
91
+
92
+ it('renders offline UI when navigator reports offline, even without a network-typed error', () => {
93
+ setOnLine(false)
94
+ const reset = jest.fn()
95
+ render(<GlobalError error={new Error('boom')} reset={reset} />)
96
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/offline/i)
97
+ })
98
+
99
+ it('retry button reloads the page in offline mode and does not call reset', () => {
100
+ setOnLine(true)
101
+ const reset = jest.fn()
102
+ const err = Object.assign(new Error('Failed to fetch'), { name: 'TypeError' })
103
+ render(<GlobalError error={err} reset={reset} />)
104
+ fireEvent.click(screen.getByRole('button', { name: /retry now/i }))
105
+ expect(reloadMock).toHaveBeenCalledTimes(1)
106
+ expect(reset).not.toHaveBeenCalled()
107
+ })
108
+
109
+ it('auto-reloads the page when the browser regains connectivity after a network error', () => {
110
+ setOnLine(false)
111
+ const reset = jest.fn()
112
+ const err = Object.assign(new Error('Loading chunk 3 failed'), { name: 'ChunkLoadError' })
113
+ render(<GlobalError error={err} reset={reset} />)
114
+ act(() => {
115
+ window.dispatchEvent(new Event('online'))
116
+ })
117
+ expect(reloadMock).toHaveBeenCalledTimes(1)
118
+ })
119
+
120
+ it('does not auto-reload on "online" event when the original error was not a network error', () => {
121
+ setOnLine(false)
122
+ const reset = jest.fn()
123
+ const err = Object.assign(new Error('application crashed'), { name: 'TypeError' })
124
+ render(<GlobalError error={err} reset={reset} />)
125
+ act(() => {
126
+ window.dispatchEvent(new Event('online'))
127
+ })
128
+ expect(reloadMock).not.toHaveBeenCalled()
129
+ })
130
+ })
@@ -48,6 +48,8 @@ TODO: Fix that latter to have reference by the package names
48
48
  --color-secondary: var(--secondary);
49
49
  --color-primary-foreground: var(--primary-foreground);
50
50
  --color-primary: var(--primary);
51
+ --color-brand-violet: var(--brand-violet);
52
+ --color-brand-violet-foreground: var(--brand-violet-foreground);
51
53
  --color-popover-foreground: var(--popover-foreground);
52
54
  --color-popover: var(--popover);
53
55
  --color-card-foreground: var(--card-foreground);
@@ -110,6 +112,8 @@ TODO: Fix that latter to have reference by the package names
110
112
  --popover-foreground: oklch(0.145 0 0);
111
113
  --primary: oklch(0.205 0 0);
112
114
  --primary-foreground: oklch(0.985 0 0);
115
+ --brand-violet: oklch(0.55 0.2 293);
116
+ --brand-violet-foreground: oklch(0.985 0 0);
113
117
  --secondary: oklch(0.97 0 0);
114
118
  --secondary-foreground: oklch(0.205 0 0);
115
119
  --muted: oklch(0.97 0 0);
@@ -187,6 +191,8 @@ TODO: Fix that latter to have reference by the package names
187
191
  --popover-foreground: oklch(0.985 0 0);
188
192
  --primary: oklch(0.922 0 0);
189
193
  --primary-foreground: oklch(0.205 0 0);
194
+ --brand-violet: oklch(0.65 0.2 293);
195
+ --brand-violet-foreground: oklch(0.985 0 0);
190
196
  --secondary: oklch(0.269 0 0);
191
197
  --secondary-foreground: oklch(0.985 0 0);
192
198
  --muted: oklch(0.269 0 0);
@@ -433,3 +439,8 @@ TODO: Fix that latter to have reference by the package names
433
439
  .bg-gradient-conic {
434
440
  background: conic-gradient(from 0deg, var(--tw-gradient-stops));
435
441
  }
442
+
443
+ /* Hide floating demo feedback button while the column chooser panel is open */
444
+ body[data-column-chooser-open="true"] .om-demo-feedback-floating {
445
+ display: none !important;
446
+ }
@@ -503,6 +503,8 @@
503
503
  "ui.badges.severity.high": "Hoch",
504
504
  "ui.badges.severity.low": "Niedrig",
505
505
  "ui.badges.severity.medium": "Mittel",
506
+ "ui.collapsible.errorPlural": "{{count}} errors",
507
+ "ui.collapsible.errorSingular": "{{count}} error",
506
508
  "ui.contextHelp.hide": "Ausblenden",
507
509
  "ui.contextHelp.show": "Anzeigen",
508
510
  "ui.dataLoader.loading": "Wird geladen...",
@@ -637,6 +639,10 @@
637
639
  "ui.userMenu.logout": "Abmelden",
638
640
  "ui.userMenu.profile": "Profil",
639
641
  "ui.userMenu.userFallback": "Benutzer",
642
+ "ui.zone.collapse": "Collapse form panel",
643
+ "ui.zone.expand": "Expand form panel",
644
+ "ui.zone.unsavedChanges": "Unsaved changes",
645
+ "ui.zone.validationErrors": "{{count}} validation error(s)",
640
646
  "upgrades.loading": "Upgrade wird geladen…",
641
647
  "upgrades.runFailed": "Die Upgrade-Aktion konnte nicht ausgeführt werden.",
642
648
  "upgrades.scopeRequired": "Wähle eine Organisation und einen Tenant, um dieses Upgrade anzuwenden.",
@@ -621,6 +621,8 @@
621
621
  "ui.badges.severity.high": "High",
622
622
  "ui.badges.severity.low": "Low",
623
623
  "ui.badges.severity.medium": "Medium",
624
+ "ui.collapsible.errorPlural": "{{count}} errors",
625
+ "ui.collapsible.errorSingular": "{{count}} error",
624
626
  "ui.contextHelp.hide": "Hide",
625
627
  "ui.contextHelp.show": "Show",
626
628
  "ui.dataLoader.loading": "Loading...",
@@ -775,6 +777,10 @@
775
777
  "ui.wizard.steps": "Steps",
776
778
  "ui.wizard.validating": "Validating…",
777
779
  "ui.wizard.validationFailed": "Validation failed.",
780
+ "ui.zone.collapse": "Collapse form panel",
781
+ "ui.zone.expand": "Expand form panel",
782
+ "ui.zone.unsavedChanges": "Unsaved changes",
783
+ "ui.zone.validationErrors": "{{count}} validation error(s)",
778
784
  "update": "Update",
779
785
  "updateSuccess": "Updated successfully.",
780
786
  "upgrades.loading": "Loading upgrade…",
@@ -502,6 +502,8 @@
502
502
  "ui.badges.severity.high": "Alto",
503
503
  "ui.badges.severity.low": "Bajo",
504
504
  "ui.badges.severity.medium": "Medio",
505
+ "ui.collapsible.errorPlural": "{{count}} errors",
506
+ "ui.collapsible.errorSingular": "{{count}} error",
505
507
  "ui.contextHelp.hide": "Ocultar",
506
508
  "ui.contextHelp.show": "Mostrar",
507
509
  "ui.dataLoader.loading": "Cargando...",
@@ -636,6 +638,10 @@
636
638
  "ui.userMenu.logout": "Cerrar sesión",
637
639
  "ui.userMenu.profile": "Perfil",
638
640
  "ui.userMenu.userFallback": "Usuario",
641
+ "ui.zone.collapse": "Collapse form panel",
642
+ "ui.zone.expand": "Expand form panel",
643
+ "ui.zone.unsavedChanges": "Unsaved changes",
644
+ "ui.zone.validationErrors": "{{count}} validation error(s)",
639
645
  "upgrades.loading": "Cargando actualización…",
640
646
  "upgrades.runFailed": "No pudimos ejecutar esta acción de actualización.",
641
647
  "upgrades.scopeRequired": "Selecciona una organización y un tenant para aplicar esta actualización.",
@@ -478,6 +478,8 @@
478
478
  "ui.badges.severity.high": "Wysoki",
479
479
  "ui.badges.severity.low": "Niski",
480
480
  "ui.badges.severity.medium": "Średni",
481
+ "ui.collapsible.errorPlural": "{{count}} errors",
482
+ "ui.collapsible.errorSingular": "{{count}} error",
481
483
  "ui.contextHelp.hide": "Ukryj",
482
484
  "ui.contextHelp.show": "Pokaż",
483
485
  "ui.dataLoader.loading": "Ładowanie...",
@@ -612,6 +614,10 @@
612
614
  "ui.userMenu.logout": "Wyloguj",
613
615
  "ui.userMenu.profile": "Profil",
614
616
  "ui.userMenu.userFallback": "Użytkownik",
617
+ "ui.zone.collapse": "Collapse form panel",
618
+ "ui.zone.expand": "Expand form panel",
619
+ "ui.zone.unsavedChanges": "Unsaved changes",
620
+ "ui.zone.validationErrors": "{{count}} validation error(s)",
615
621
  "upgrades.loading": "Ładowanie aktualizacji…",
616
622
  "upgrades.runFailed": "Nie udało się uruchomić tej akcji aktualizacji.",
617
623
  "upgrades.scopeRequired": "Wybierz organizację i tenant, aby zastosować tę aktualizację.",
@@ -0,0 +1,243 @@
1
+ import { expect, test, type APIRequestContext } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { expectId, getTokenContext, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+
5
+ type IdResponse = {
6
+ id?: string | null
7
+ }
8
+
9
+ type PriorityListResponse = {
10
+ items?: Array<{
11
+ id?: string | null
12
+ priority?: string | null
13
+ }>
14
+ }
15
+
16
+ function buildScopeCookie(
17
+ tenantId: string,
18
+ organizationId: string | null,
19
+ options?: { padOrganization?: boolean },
20
+ ): string {
21
+ const parts = [`om_selected_tenant=${encodeURIComponent(tenantId)}`]
22
+ if (organizationId) {
23
+ const scopedOrganizationId = options?.padOrganization
24
+ ? ` ${organizationId} `
25
+ : organizationId
26
+ parts.push(`om_selected_org=${encodeURIComponent(scopedOrganizationId)}`)
27
+ }
28
+ return parts.join('; ')
29
+ }
30
+
31
+ async function scopedApiRequest(
32
+ request: APIRequestContext,
33
+ method: string,
34
+ path: string,
35
+ options: { token: string; cookie: string; data?: unknown },
36
+ ) {
37
+ return request.fetch(path, {
38
+ method,
39
+ headers: {
40
+ Authorization: `Bearer ${options.token}`,
41
+ 'Content-Type': 'application/json',
42
+ Cookie: options.cookie,
43
+ },
44
+ data: options.data,
45
+ })
46
+ }
47
+
48
+ async function createOrganizationInScope(
49
+ request: APIRequestContext,
50
+ token: string,
51
+ cookie: string,
52
+ input: { name: string; tenantId: string },
53
+ ): Promise<string> {
54
+ const response = await scopedApiRequest(request, 'POST', '/api/directory/organizations', {
55
+ token,
56
+ cookie,
57
+ data: input,
58
+ })
59
+ const body = await readJsonSafe<IdResponse>(response)
60
+ expect(response.ok(), `POST /api/directory/organizations failed with ${response.status()}`).toBeTruthy()
61
+ return expectId(body?.id, 'Organization create response should include id')
62
+ }
63
+
64
+ async function createTenant(
65
+ request: APIRequestContext,
66
+ token: string,
67
+ input: { name: string },
68
+ ): Promise<string> {
69
+ const response = await request.fetch('/api/directory/tenants', {
70
+ method: 'POST',
71
+ headers: {
72
+ Authorization: `Bearer ${token}`,
73
+ 'Content-Type': 'application/json',
74
+ },
75
+ data: input,
76
+ })
77
+ const body = await readJsonSafe<IdResponse>(response)
78
+ expect(response.ok(), `POST /api/directory/tenants failed with ${response.status()}`).toBeTruthy()
79
+ return expectId(body?.id, 'Tenant create response should include id')
80
+ }
81
+
82
+ async function createPersonInScope(
83
+ request: APIRequestContext,
84
+ token: string,
85
+ cookie: string,
86
+ input: { firstName: string; lastName: string; displayName: string },
87
+ ): Promise<string> {
88
+ const response = await scopedApiRequest(request, 'POST', '/api/customers/people', {
89
+ token,
90
+ cookie,
91
+ data: input,
92
+ })
93
+ const body = await readJsonSafe<IdResponse>(response)
94
+ expect(response.ok(), `POST /api/customers/people failed with ${response.status()}`).toBeTruthy()
95
+ return expectId(body?.id, 'Person create response should include id')
96
+ }
97
+
98
+ async function createPriorityInScope(
99
+ request: APIRequestContext,
100
+ token: string,
101
+ cookie: string,
102
+ input: { customerId: string; priority: 'low' | 'normal' | 'high' | 'critical' },
103
+ ): Promise<string> {
104
+ const response = await scopedApiRequest(request, 'POST', '/api/example/customer-priorities', {
105
+ token,
106
+ cookie,
107
+ data: input,
108
+ })
109
+ const body = await readJsonSafe<IdResponse>(response)
110
+ expect(response.ok(), `POST /api/example/customer-priorities failed with ${response.status()}`).toBeTruthy()
111
+ return expectId(body?.id, 'Priority create response should include id')
112
+ }
113
+
114
+ async function listPrioritiesInScope(
115
+ request: APIRequestContext,
116
+ token: string,
117
+ cookie: string,
118
+ customerId: string,
119
+ ) {
120
+ const response = await scopedApiRequest(
121
+ request,
122
+ 'GET',
123
+ `/api/example/customer-priorities?customerId=${encodeURIComponent(customerId)}&page=1&pageSize=10`,
124
+ { token, cookie },
125
+ )
126
+ expect(response.ok(), `GET /api/example/customer-priorities failed with ${response.status()}`).toBeTruthy()
127
+ const body = await readJsonSafe<PriorityListResponse>(response)
128
+ return Array.isArray(body?.items) ? body.items : []
129
+ }
130
+
131
+ async function deleteByQueryIfExists(
132
+ request: APIRequestContext,
133
+ token: string,
134
+ cookie: string,
135
+ path: string,
136
+ id: string | null,
137
+ ): Promise<void> {
138
+ if (!id) return
139
+ await scopedApiRequest(request, 'DELETE', `${path}?id=${encodeURIComponent(id)}`, { token, cookie }).catch(() => {})
140
+ }
141
+
142
+ async function deleteByBodyIfExists(
143
+ request: APIRequestContext,
144
+ token: string,
145
+ cookie: string,
146
+ path: string,
147
+ id: string | null,
148
+ ): Promise<void> {
149
+ if (!id) return
150
+ await scopedApiRequest(request, 'DELETE', path, { token, cookie, data: { id } }).catch(() => {})
151
+ }
152
+
153
+ /**
154
+ * TC-UMES-021: shared CRUD scope helper trims whitespace-padded organization scope during superadmin tenant override
155
+ */
156
+ test.describe('TC-UMES-021: shared CRUD scope helper trims whitespace-padded organization scope during superadmin tenant override', () => {
157
+ test('should normalize whitespace-padded selected organization ids for direct update/delete', async ({ request }) => {
158
+ const token = await getAuthToken(request, 'superadmin')
159
+ const { tenantId: actorTenantId, organizationId: actorOrganizationId } = getTokenContext(token)
160
+ expect(actorTenantId, 'Superadmin token should include a tenant id').toBeTruthy()
161
+
162
+ const actorScopeCookie = buildScopeCookie(actorTenantId, actorOrganizationId || null)
163
+ const suffix = Date.now()
164
+ let targetTenantId: string | null = null
165
+ let targetOrganizationId: string | null = null
166
+ let targetPersonId: string | null = null
167
+ let targetPriorityId: string | null = null
168
+
169
+ try {
170
+ targetTenantId = await createTenant(request, token, {
171
+ name: `QA TC-UMES-021 Tenant ${suffix}`,
172
+ })
173
+ targetOrganizationId = await createOrganizationInScope(request, token, actorScopeCookie, {
174
+ name: `QA TC-UMES-021 Org ${suffix}`,
175
+ tenantId: targetTenantId,
176
+ })
177
+
178
+ const exactTargetCookie = buildScopeCookie(targetTenantId, targetOrganizationId)
179
+ const paddedTargetCookie = buildScopeCookie(targetTenantId, targetOrganizationId, {
180
+ padOrganization: true,
181
+ })
182
+
183
+ targetPersonId = await createPersonInScope(request, token, exactTargetCookie, {
184
+ firstName: `QA-${suffix}`,
185
+ lastName: 'ScopeTrim',
186
+ displayName: `QA Scope Trim ${suffix}`,
187
+ })
188
+
189
+ targetPriorityId = await createPriorityInScope(request, token, exactTargetCookie, {
190
+ customerId: targetPersonId,
191
+ priority: 'normal',
192
+ })
193
+
194
+ const trimmedScopeUpdate = await scopedApiRequest(request, 'PUT', '/api/example/customer-priorities', {
195
+ token,
196
+ cookie: paddedTargetCookie,
197
+ data: { id: targetPriorityId, priority: 'critical' },
198
+ })
199
+ expect(
200
+ trimmedScopeUpdate.ok(),
201
+ 'Whitespace-padded selected organization should still match the target record during superadmin tenant override update',
202
+ ).toBeTruthy()
203
+
204
+ const prioritiesAfterUpdate = await listPrioritiesInScope(
205
+ request,
206
+ token,
207
+ exactTargetCookie,
208
+ targetPersonId,
209
+ )
210
+ expect(prioritiesAfterUpdate).toHaveLength(1)
211
+ expect(prioritiesAfterUpdate[0]?.priority).toBe('critical')
212
+
213
+ const trimmedScopeDelete = await scopedApiRequest(request, 'DELETE', '/api/example/customer-priorities', {
214
+ token,
215
+ cookie: paddedTargetCookie,
216
+ data: { id: targetPriorityId },
217
+ })
218
+ expect(
219
+ trimmedScopeDelete.ok(),
220
+ 'Whitespace-padded selected organization should still match the target record during superadmin tenant override delete',
221
+ ).toBeTruthy()
222
+ targetPriorityId = null
223
+
224
+ const remainingPriorities = await listPrioritiesInScope(
225
+ request,
226
+ token,
227
+ exactTargetCookie,
228
+ targetPersonId,
229
+ )
230
+ expect(remainingPriorities).toHaveLength(0)
231
+ } finally {
232
+ const exactTargetCookie =
233
+ targetTenantId && targetOrganizationId
234
+ ? buildScopeCookie(targetTenantId, targetOrganizationId)
235
+ : actorScopeCookie
236
+
237
+ await deleteByBodyIfExists(request, token, exactTargetCookie, '/api/example/customer-priorities', targetPriorityId)
238
+ await deleteByQueryIfExists(request, token, exactTargetCookie, '/api/customers/people', targetPersonId)
239
+ await deleteByQueryIfExists(request, token, actorScopeCookie, '/api/directory/organizations', targetOrganizationId)
240
+ await deleteByQueryIfExists(request, token, actorScopeCookie, '/api/directory/tenants', targetTenantId)
241
+ }
242
+ })
243
+ })
@@ -232,7 +232,7 @@ export default function UmesExtensionsPage() {
232
232
 
233
233
  return (
234
234
  <Page>
235
- <PageBody className="space-y-4" data-component-handle={ComponentReplacementHandles.page('/backend/umes-extensions')}>
235
+ <PageBody className="space-y-4">
236
236
  <div>
237
237
  <h1 className="text-xl font-semibold">{t('example.umes.extensions.title', 'UMES Phase E-H Extensions')}</h1>
238
238
  <p className="text-sm text-muted-foreground">
@@ -0,0 +1,114 @@
1
+ import { createHmac } from 'node:crypto'
2
+ import {
3
+ MOCK_CARRIER_DEV_WEBHOOK_SECRET,
4
+ MOCK_CARRIER_SIGNATURE_HEADER,
5
+ computeMockCarrierWebhookSignature,
6
+ mockShippingAdapter,
7
+ } from '../mock-shipping-adapter'
8
+
9
+ const ORIGINAL_ENV = process.env.MOCK_CARRIER_WEBHOOK_SECRET
10
+ const ORIGINAL_NODE_ENV = process.env.NODE_ENV
11
+
12
+ function resetEnv() {
13
+ if (ORIGINAL_ENV === undefined) delete process.env.MOCK_CARRIER_WEBHOOK_SECRET
14
+ else process.env.MOCK_CARRIER_WEBHOOK_SECRET = ORIGINAL_ENV
15
+ if (ORIGINAL_NODE_ENV === undefined) delete process.env.NODE_ENV
16
+ else process.env.NODE_ENV = ORIGINAL_NODE_ENV
17
+ }
18
+
19
+ describe('mockShippingAdapter.verifyWebhook', () => {
20
+ afterEach(() => {
21
+ resetEnv()
22
+ })
23
+
24
+ it('throws when the signature header is missing', async () => {
25
+ const rawBody = JSON.stringify({ type: 'shipment.delivered', data: {} })
26
+ await expect(
27
+ mockShippingAdapter.verifyWebhook({
28
+ rawBody,
29
+ headers: {},
30
+ credentials: {},
31
+ }),
32
+ ).rejects.toThrow(/Missing .* header/)
33
+ })
34
+
35
+ it('throws when the signature does not match the dev secret', async () => {
36
+ const rawBody = JSON.stringify({ type: 'shipment.delivered', data: { shipmentId: 'mock_shp_1' } })
37
+ const bogus = createHmac('sha256', 'wrong-secret').update(rawBody, 'utf-8').digest('hex')
38
+ await expect(
39
+ mockShippingAdapter.verifyWebhook({
40
+ rawBody,
41
+ headers: { [MOCK_CARRIER_SIGNATURE_HEADER]: bogus },
42
+ credentials: {},
43
+ }),
44
+ ).rejects.toThrow(/Invalid mock carrier webhook signature/)
45
+ })
46
+
47
+ it('rejects signatures that were valid for a different body (tamper detection)', async () => {
48
+ const originalBody = JSON.stringify({ type: 'shipment.delivered', data: { shipmentId: 'good' } })
49
+ const tamperedBody = JSON.stringify({ type: 'shipment.delivered', data: { shipmentId: 'evil' } })
50
+ const goodSig = computeMockCarrierWebhookSignature(originalBody, MOCK_CARRIER_DEV_WEBHOOK_SECRET)
51
+ await expect(
52
+ mockShippingAdapter.verifyWebhook({
53
+ rawBody: tamperedBody,
54
+ headers: { [MOCK_CARRIER_SIGNATURE_HEADER]: goodSig },
55
+ credentials: {},
56
+ }),
57
+ ).rejects.toThrow(/Invalid mock carrier webhook signature/)
58
+ })
59
+
60
+ it('accepts a payload signed with the dev fallback secret', async () => {
61
+ const rawBody = JSON.stringify({
62
+ type: 'shipment.delivered',
63
+ id: 'evt_abc',
64
+ data: { shipmentId: 'mock_shp_abc', status: 'delivered' },
65
+ })
66
+ const sig = computeMockCarrierWebhookSignature(rawBody, MOCK_CARRIER_DEV_WEBHOOK_SECRET)
67
+ const event = await mockShippingAdapter.verifyWebhook({
68
+ rawBody,
69
+ headers: { [MOCK_CARRIER_SIGNATURE_HEADER]: sig },
70
+ credentials: {},
71
+ })
72
+ expect(event.eventType).toBe('shipment.delivered')
73
+ expect(event.eventId).toBe('evt_abc')
74
+ })
75
+
76
+ it('prefers credentials.webhookSecret over the env fallback', async () => {
77
+ process.env.MOCK_CARRIER_WEBHOOK_SECRET = 'env-only-secret'
78
+ const rawBody = JSON.stringify({ type: 'shipment.in_transit', data: {} })
79
+ const sig = computeMockCarrierWebhookSignature(rawBody, 'tenant-secret')
80
+ const event = await mockShippingAdapter.verifyWebhook({
81
+ rawBody,
82
+ headers: { [MOCK_CARRIER_SIGNATURE_HEADER]: sig },
83
+ credentials: { webhookSecret: 'tenant-secret' },
84
+ })
85
+ expect(event.eventType).toBe('shipment.in_transit')
86
+ })
87
+
88
+ it('falls back to MOCK_CARRIER_WEBHOOK_SECRET when credentials omit the secret', async () => {
89
+ process.env.MOCK_CARRIER_WEBHOOK_SECRET = 'env-only-secret'
90
+ const rawBody = JSON.stringify({ type: 'shipment.in_transit', data: {} })
91
+ const sig = computeMockCarrierWebhookSignature(rawBody, 'env-only-secret')
92
+ await expect(
93
+ mockShippingAdapter.verifyWebhook({
94
+ rawBody,
95
+ headers: { [MOCK_CARRIER_SIGNATURE_HEADER]: sig },
96
+ credentials: {},
97
+ }),
98
+ ).resolves.toMatchObject({ eventType: 'shipment.in_transit' })
99
+ })
100
+
101
+ it('refuses to fall back to the dev secret in production', async () => {
102
+ process.env.NODE_ENV = 'production'
103
+ delete process.env.MOCK_CARRIER_WEBHOOK_SECRET
104
+ const rawBody = JSON.stringify({ type: 'shipment.delivered', data: {} })
105
+ const sig = computeMockCarrierWebhookSignature(rawBody, MOCK_CARRIER_DEV_WEBHOOK_SECRET)
106
+ await expect(
107
+ mockShippingAdapter.verifyWebhook({
108
+ rawBody,
109
+ headers: { [MOCK_CARRIER_SIGNATURE_HEADER]: sig },
110
+ credentials: {},
111
+ }),
112
+ ).rejects.toThrow(/Mock carrier webhook secret is not configured/)
113
+ })
114
+ })