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 +1 -1
- package/template/scripts/dev-runtime.mjs +16 -1
- package/template/src/app/__tests__/global-error.test.tsx +130 -0
- package/template/src/app/globals.css +11 -0
- package/template/src/i18n/de.json +6 -0
- package/template/src/i18n/en.json +6 -0
- package/template/src/i18n/es.json +6 -0
- package/template/src/i18n/pl.json +6 -0
- package/template/src/modules/example/__integration__/TC-UMES-021.spec.ts +243 -0
- package/template/src/modules/example/backend/umes-extensions/page.tsx +1 -1
- package/template/src/modules/example/lib/__tests__/mock-shipping-adapter.test.ts +114 -0
package/package.json
CHANGED
|
@@ -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"
|
|
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
|
+
})
|