@open-mercato/tillio 0.7.1-develop.7176.1.d229567a50
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 +2 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js +17 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js +27 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js +47 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js +11 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js +13 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js.map +7 -0
- package/dist/modules/tillio/acl.js +9 -0
- package/dist/modules/tillio/acl.js.map +7 -0
- package/dist/modules/tillio/api/operators/[id]/route.js +78 -0
- package/dist/modules/tillio/api/operators/[id]/route.js.map +7 -0
- package/dist/modules/tillio/api/operators/route.js +171 -0
- package/dist/modules/tillio/api/operators/route.js.map +7 -0
- package/dist/modules/tillio/api/pull/route.js +180 -0
- package/dist/modules/tillio/api/pull/route.js.map +7 -0
- package/dist/modules/tillio/cli.js +108 -0
- package/dist/modules/tillio/cli.js.map +7 -0
- package/dist/modules/tillio/di.js +16 -0
- package/dist/modules/tillio/di.js.map +7 -0
- package/dist/modules/tillio/index.js +12 -0
- package/dist/modules/tillio/index.js.map +7 -0
- package/dist/modules/tillio/integration.js +62 -0
- package/dist/modules/tillio/integration.js.map +7 -0
- package/dist/modules/tillio/lib/adapter.js +107 -0
- package/dist/modules/tillio/lib/adapter.js.map +7 -0
- package/dist/modules/tillio/lib/client.js +249 -0
- package/dist/modules/tillio/lib/client.js.map +7 -0
- package/dist/modules/tillio/lib/environment.js +34 -0
- package/dist/modules/tillio/lib/environment.js.map +7 -0
- package/dist/modules/tillio/lib/error-codes.js +59 -0
- package/dist/modules/tillio/lib/error-codes.js.map +7 -0
- package/dist/modules/tillio/lib/errors.js +12 -0
- package/dist/modules/tillio/lib/errors.js.map +7 -0
- package/dist/modules/tillio/lib/health.js +41 -0
- package/dist/modules/tillio/lib/health.js.map +7 -0
- package/dist/modules/tillio/lib/locking.js +18 -0
- package/dist/modules/tillio/lib/locking.js.map +7 -0
- package/dist/modules/tillio/lib/normalizer.js +134 -0
- package/dist/modules/tillio/lib/normalizer.js.map +7 -0
- package/dist/modules/tillio/lib/operators-store.js +62 -0
- package/dist/modules/tillio/lib/operators-store.js.map +7 -0
- package/dist/modules/tillio/lib/operators.js +168 -0
- package/dist/modules/tillio/lib/operators.js.map +7 -0
- package/dist/modules/tillio/lib/preset.js +122 -0
- package/dist/modules/tillio/lib/preset.js.map +7 -0
- package/dist/modules/tillio/lib/pull-job.js +265 -0
- package/dist/modules/tillio/lib/pull-job.js.map +7 -0
- package/dist/modules/tillio/lib/pull-readiness.js +36 -0
- package/dist/modules/tillio/lib/pull-readiness.js.map +7 -0
- package/dist/modules/tillio/lib/queue.js +22 -0
- package/dist/modules/tillio/lib/queue.js.map +7 -0
- package/dist/modules/tillio/lib/tz.js +85 -0
- package/dist/modules/tillio/lib/tz.js.map +7 -0
- package/dist/modules/tillio/lib/url-guard.js +24 -0
- package/dist/modules/tillio/lib/url-guard.js.map +7 -0
- package/dist/modules/tillio/setup.js +45 -0
- package/dist/modules/tillio/setup.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js +208 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.js +17 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js +148 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.js +17 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.js.map +7 -0
- package/dist/modules/tillio/widgets/injection-table.js +21 -0
- package/dist/modules/tillio/widgets/injection-table.js.map +7 -0
- package/dist/modules/tillio/workers/tillio-pull.js +17 -0
- package/dist/modules/tillio/workers/tillio-pull.js.map +7 -0
- package/jest.config.cjs +35 -0
- package/package.json +92 -0
- package/src/index.ts +1 -0
- package/src/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.ts +38 -0
- package/src/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.ts +44 -0
- package/src/modules/tillio/__integration__/TC-PHONE-003-operators.spec.ts +76 -0
- package/src/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.ts +22 -0
- package/src/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.ts +27 -0
- package/src/modules/tillio/__tests__/adapter-fetch-calls.test.ts +148 -0
- package/src/modules/tillio/__tests__/client.test.ts +203 -0
- package/src/modules/tillio/__tests__/health.test.ts +122 -0
- package/src/modules/tillio/__tests__/normalizer.test.ts +143 -0
- package/src/modules/tillio/__tests__/operators-routes.test.ts +100 -0
- package/src/modules/tillio/__tests__/operators-store.test.ts +104 -0
- package/src/modules/tillio/__tests__/operators.test.ts +250 -0
- package/src/modules/tillio/__tests__/preset.test.ts +337 -0
- package/src/modules/tillio/__tests__/pull-body.test.ts +43 -0
- package/src/modules/tillio/__tests__/pull-job.test.ts +250 -0
- package/src/modules/tillio/__tests__/pull-readiness.test.ts +143 -0
- package/src/modules/tillio/__tests__/pull-route.test.ts +148 -0
- package/src/modules/tillio/__tests__/tz.test.ts +53 -0
- package/src/modules/tillio/__tests__/url-guard.test.ts +46 -0
- package/src/modules/tillio/acl.ts +5 -0
- package/src/modules/tillio/api/operators/[id]/route.ts +90 -0
- package/src/modules/tillio/api/operators/route.ts +191 -0
- package/src/modules/tillio/api/pull/route.ts +213 -0
- package/src/modules/tillio/cli.ts +125 -0
- package/src/modules/tillio/di.ts +16 -0
- package/src/modules/tillio/i18n/de.json +73 -0
- package/src/modules/tillio/i18n/en.json +73 -0
- package/src/modules/tillio/i18n/es.json +73 -0
- package/src/modules/tillio/i18n/ko.json +73 -0
- package/src/modules/tillio/i18n/pl.json +73 -0
- package/src/modules/tillio/index.ts +8 -0
- package/src/modules/tillio/integration.ts +58 -0
- package/src/modules/tillio/lib/adapter.ts +135 -0
- package/src/modules/tillio/lib/client.ts +329 -0
- package/src/modules/tillio/lib/environment.ts +53 -0
- package/src/modules/tillio/lib/error-codes.ts +67 -0
- package/src/modules/tillio/lib/errors.ts +11 -0
- package/src/modules/tillio/lib/health.ts +57 -0
- package/src/modules/tillio/lib/locking.ts +30 -0
- package/src/modules/tillio/lib/normalizer.ts +184 -0
- package/src/modules/tillio/lib/operators-store.ts +77 -0
- package/src/modules/tillio/lib/operators.ts +269 -0
- package/src/modules/tillio/lib/preset.ts +226 -0
- package/src/modules/tillio/lib/pull-job.ts +390 -0
- package/src/modules/tillio/lib/pull-readiness.ts +77 -0
- package/src/modules/tillio/lib/queue.ts +33 -0
- package/src/modules/tillio/lib/tz.ts +121 -0
- package/src/modules/tillio/lib/url-guard.ts +34 -0
- package/src/modules/tillio/setup.ts +49 -0
- package/src/modules/tillio/widgets/injection/operators-config/widget.client.tsx +304 -0
- package/src/modules/tillio/widgets/injection/operators-config/widget.ts +16 -0
- package/src/modules/tillio/widgets/injection/pull-calls/widget.client.tsx +214 -0
- package/src/modules/tillio/widgets/injection/pull-calls/widget.ts +16 -0
- package/src/modules/tillio/widgets/injection-table.ts +19 -0
- package/src/modules/tillio/workers/tillio-pull.ts +18 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import type { PhoneCallProviderAdapter } from '@open-mercato/shared/modules/phone_calls/provider'
|
|
3
|
+
import type { NormalizedPhoneCall } from '@open-mercato/shared/modules/phone_calls/types'
|
|
4
|
+
import { computeEnvFingerprint } from '../lib/operators-store'
|
|
5
|
+
import { runTillioPullJob, type TillioPullJobPayload } from '../lib/pull-job'
|
|
6
|
+
|
|
7
|
+
jest.mock('../lib/operators', () => ({
|
|
8
|
+
...jest.requireActual('../lib/operators'),
|
|
9
|
+
resolveEnvironment: jest.fn(),
|
|
10
|
+
readTillioIntegrationState: jest.fn(),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
jest.mock('../lib/operators-store', () => ({
|
|
14
|
+
...jest.requireActual('../lib/operators-store'),
|
|
15
|
+
readOperatorsBlob: jest.fn(),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
const { resolveEnvironment, readTillioIntegrationState } = jest.requireMock('../lib/operators')
|
|
19
|
+
const { readOperatorsBlob } = jest.requireMock('../lib/operators-store')
|
|
20
|
+
|
|
21
|
+
const environment = { apiUrl: 'https://api.example.com', apiKey: 'key', tenantSystemId: 'OM-1' }
|
|
22
|
+
|
|
23
|
+
const operator = {
|
|
24
|
+
id: 'op-1',
|
|
25
|
+
plugin: 'Ringostat' as const,
|
|
26
|
+
token: 'tok-1',
|
|
27
|
+
tenantDomain: 'tenant.example.com',
|
|
28
|
+
envFingerprint: computeEnvFingerprint(environment),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const payload: TillioPullJobPayload = {
|
|
32
|
+
progressJobId: 'job-1',
|
|
33
|
+
scope: { tenantId: 'tn', organizationId: 'org', userId: 'user-1' },
|
|
34
|
+
from: '2026-08-01',
|
|
35
|
+
to: '2026-08-02',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function call(externalCallId: string): NormalizedPhoneCall {
|
|
39
|
+
return {
|
|
40
|
+
externalCallId,
|
|
41
|
+
direction: 'inbound',
|
|
42
|
+
status: 'completed',
|
|
43
|
+
participants: [],
|
|
44
|
+
rawPayload: {},
|
|
45
|
+
providerFacts: {},
|
|
46
|
+
} as unknown as NormalizedPhoneCall
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createProgressService(overrides: Partial<Record<string, jest.Mock>> = {}) {
|
|
50
|
+
return {
|
|
51
|
+
startJob: jest.fn().mockResolvedValue(undefined),
|
|
52
|
+
updateProgress: jest.fn().mockResolvedValue(undefined),
|
|
53
|
+
completeJob: jest.fn().mockResolvedValue(undefined),
|
|
54
|
+
failJob: jest.fn().mockResolvedValue(undefined),
|
|
55
|
+
markCancelled: jest.fn().mockResolvedValue(undefined),
|
|
56
|
+
isCancellationRequested: jest.fn().mockResolvedValue(false),
|
|
57
|
+
...overrides,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createContainer(services: Record<string, unknown>): AwilixContainer {
|
|
62
|
+
return { resolve: (name: string) => services[name] } as unknown as AwilixContainer
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createAdapter(pages: Array<{ calls: NormalizedPhoneCall[]; nextCursor: string | null }>) {
|
|
66
|
+
const fetchCalls = jest.fn(async () => pages.shift() ?? { calls: [], nextCursor: null })
|
|
67
|
+
return { fetchCalls } as unknown as PhoneCallProviderAdapter & { fetchCalls: jest.Mock }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
resolveEnvironment.mockResolvedValue(environment)
|
|
72
|
+
readTillioIntegrationState.mockResolvedValue({ enabled: true, healthy: true })
|
|
73
|
+
readOperatorsBlob.mockResolvedValue({ operators: [operator], defaultOperatorId: operator.id })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('runTillioPullJob', () => {
|
|
77
|
+
it('follows the provider cursor to the last page and completes the job', async () => {
|
|
78
|
+
const progressService = createProgressService()
|
|
79
|
+
const commandBus = { execute: jest.fn().mockResolvedValue({ result: { created: true } }) }
|
|
80
|
+
const adapter = createAdapter([
|
|
81
|
+
{ calls: [call('a'), call('b')], nextCursor: '2' },
|
|
82
|
+
{ calls: [call('c')], nextCursor: null },
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
const summary = await runTillioPullJob({
|
|
86
|
+
container: createContainer({
|
|
87
|
+
progressService,
|
|
88
|
+
commandBus,
|
|
89
|
+
em: {},
|
|
90
|
+
integrationCredentialsService: {},
|
|
91
|
+
}),
|
|
92
|
+
payload,
|
|
93
|
+
adapter,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(summary).toMatchObject({ fetched: 3, created: 3, updated: 0, failed: 0, batches: 2 })
|
|
97
|
+
expect(adapter.fetchCalls.mock.calls[0][0].cursor).toBeNull()
|
|
98
|
+
expect(adapter.fetchCalls.mock.calls[1][0].cursor).toBe('2')
|
|
99
|
+
expect(commandBus.execute).toHaveBeenCalledTimes(3)
|
|
100
|
+
expect(progressService.completeJob).toHaveBeenCalledWith(
|
|
101
|
+
'job-1',
|
|
102
|
+
{ resultSummary: expect.objectContaining({ fetched: 3 }) },
|
|
103
|
+
expect.anything(),
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('keeps ingesting the batch after a single call fails', async () => {
|
|
108
|
+
const progressService = createProgressService()
|
|
109
|
+
const commandBus = {
|
|
110
|
+
execute: jest
|
|
111
|
+
.fn()
|
|
112
|
+
.mockResolvedValueOnce({ result: { created: true } })
|
|
113
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
114
|
+
.mockResolvedValueOnce({ result: { created: false } }),
|
|
115
|
+
}
|
|
116
|
+
const adapter = createAdapter([{ calls: [call('a'), call('b'), call('c')], nextCursor: null }])
|
|
117
|
+
|
|
118
|
+
const summary = await runTillioPullJob({
|
|
119
|
+
container: createContainer({
|
|
120
|
+
progressService,
|
|
121
|
+
commandBus,
|
|
122
|
+
em: {},
|
|
123
|
+
integrationCredentialsService: {},
|
|
124
|
+
}),
|
|
125
|
+
payload,
|
|
126
|
+
adapter,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(summary).toMatchObject({ fetched: 3, created: 1, updated: 1, failed: 1 })
|
|
130
|
+
expect(progressService.completeJob).toHaveBeenCalled()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('stops before touching the provider when the job was cancelled', async () => {
|
|
134
|
+
const progressService = createProgressService({
|
|
135
|
+
isCancellationRequested: jest.fn().mockResolvedValue(true),
|
|
136
|
+
})
|
|
137
|
+
const adapter = createAdapter([{ calls: [call('a')], nextCursor: null }])
|
|
138
|
+
|
|
139
|
+
const summary = await runTillioPullJob({
|
|
140
|
+
container: createContainer({
|
|
141
|
+
progressService,
|
|
142
|
+
commandBus: { execute: jest.fn() },
|
|
143
|
+
em: {},
|
|
144
|
+
integrationCredentialsService: {},
|
|
145
|
+
}),
|
|
146
|
+
payload,
|
|
147
|
+
adapter,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
expect(summary.cancelled).toBe(true)
|
|
151
|
+
expect(adapter.fetchCalls).not.toHaveBeenCalled()
|
|
152
|
+
expect(progressService.markCancelled).toHaveBeenCalledWith('job-1', expect.anything())
|
|
153
|
+
expect(progressService.completeJob).not.toHaveBeenCalled()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('fails the job without pulling when the operator was detached while it waited', async () => {
|
|
157
|
+
readOperatorsBlob.mockResolvedValue({ operators: [], defaultOperatorId: null })
|
|
158
|
+
const progressService = createProgressService()
|
|
159
|
+
const adapter = createAdapter([{ calls: [call('a')], nextCursor: null }])
|
|
160
|
+
|
|
161
|
+
await runTillioPullJob({
|
|
162
|
+
container: createContainer({
|
|
163
|
+
progressService,
|
|
164
|
+
commandBus: { execute: jest.fn() },
|
|
165
|
+
em: {},
|
|
166
|
+
integrationCredentialsService: {},
|
|
167
|
+
}),
|
|
168
|
+
payload,
|
|
169
|
+
adapter,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
expect(adapter.fetchCalls).not.toHaveBeenCalled()
|
|
173
|
+
expect(progressService.failJob).toHaveBeenCalledWith(
|
|
174
|
+
'job-1',
|
|
175
|
+
expect.objectContaining({ resultSummary: expect.objectContaining({ code: 'operator_missing' }) }),
|
|
176
|
+
expect.anything(),
|
|
177
|
+
)
|
|
178
|
+
// The bar prints errorMessage verbatim, so a bare code must never land there.
|
|
179
|
+
expect(progressService.failJob.mock.calls[0][1].errorMessage).not.toBe('operator_missing')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('fails the job with the resume cursor when the batch cap ends the sweep', async () => {
|
|
183
|
+
const progressService = createProgressService()
|
|
184
|
+
const commandBus = { execute: jest.fn().mockResolvedValue({ result: { created: true } }) }
|
|
185
|
+
// Never stops advertising a next page, which is exactly what the cap exists for.
|
|
186
|
+
const adapter = {
|
|
187
|
+
fetchCalls: jest.fn(async (input: { cursor: string | null }) => ({
|
|
188
|
+
calls: [call(`c-${input.cursor ?? '1'}`)],
|
|
189
|
+
nextCursor: String(Number(input.cursor ?? '1') + 1),
|
|
190
|
+
})),
|
|
191
|
+
} as unknown as PhoneCallProviderAdapter & { fetchCalls: jest.Mock }
|
|
192
|
+
|
|
193
|
+
const summary = await runTillioPullJob({
|
|
194
|
+
container: createContainer({
|
|
195
|
+
progressService,
|
|
196
|
+
commandBus,
|
|
197
|
+
em: {},
|
|
198
|
+
integrationCredentialsService: {},
|
|
199
|
+
}),
|
|
200
|
+
payload,
|
|
201
|
+
adapter,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
expect(summary.truncated).toBe(true)
|
|
205
|
+
expect(summary.resumeCursor).not.toBeNull()
|
|
206
|
+
expect(progressService.completeJob).not.toHaveBeenCalled()
|
|
207
|
+
expect(progressService.failJob).toHaveBeenCalledWith(
|
|
208
|
+
'job-1',
|
|
209
|
+
expect.objectContaining({
|
|
210
|
+
resultSummary: expect.objectContaining({ code: 'pull_truncated', truncated: true }),
|
|
211
|
+
}),
|
|
212
|
+
expect.anything(),
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('fails the job when the provider stops echoing the requested page', async () => {
|
|
217
|
+
const progressService = createProgressService()
|
|
218
|
+
const commandBus = { execute: jest.fn().mockResolvedValue({ result: { created: true } }) }
|
|
219
|
+
const adapter = {
|
|
220
|
+
fetchCalls: jest.fn(async () => ({
|
|
221
|
+
calls: [call('a')],
|
|
222
|
+
nextCursor: null,
|
|
223
|
+
anomaly: 'page_not_echoed' as const,
|
|
224
|
+
anomalyCursor: '3',
|
|
225
|
+
})),
|
|
226
|
+
} as unknown as PhoneCallProviderAdapter & { fetchCalls: jest.Mock }
|
|
227
|
+
|
|
228
|
+
const summary = await runTillioPullJob({
|
|
229
|
+
container: createContainer({
|
|
230
|
+
progressService,
|
|
231
|
+
commandBus,
|
|
232
|
+
em: {},
|
|
233
|
+
integrationCredentialsService: {},
|
|
234
|
+
}),
|
|
235
|
+
payload,
|
|
236
|
+
adapter,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
expect(summary.truncated).toBe(true)
|
|
240
|
+
expect(summary.resumeCursor).toBe('3')
|
|
241
|
+
expect(progressService.completeJob).not.toHaveBeenCalled()
|
|
242
|
+
expect(progressService.failJob).toHaveBeenCalledWith(
|
|
243
|
+
'job-1',
|
|
244
|
+
expect.objectContaining({
|
|
245
|
+
resultSummary: expect.objectContaining({ code: 'pull_pagination_anomaly', anomaly: 'page_not_echoed' }),
|
|
246
|
+
}),
|
|
247
|
+
expect.anything(),
|
|
248
|
+
)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { computeEnvFingerprint } from '../lib/operators-store'
|
|
2
|
+
import { PULL_BLOCKER_COPY } from '../lib/error-codes'
|
|
3
|
+
import { blockerSection, evaluateEnvironmentReadiness, evaluatePullReadiness, type PullBlocker } from '../lib/pull-readiness'
|
|
4
|
+
import englishCatalog from '../i18n/en.json'
|
|
5
|
+
|
|
6
|
+
const environment = { apiUrl: 'https://x.example.com', apiKey: 'k', tenantSystemId: 'OM-abc' }
|
|
7
|
+
|
|
8
|
+
function operatorWith(envFingerprint: string) {
|
|
9
|
+
return {
|
|
10
|
+
id: 'ringostat-1',
|
|
11
|
+
plugin: 'Ringostat' as const,
|
|
12
|
+
config: { key: 'secret' },
|
|
13
|
+
token: 'tok',
|
|
14
|
+
tenantDomain: 'app.example.com/OM-abc-ringostat-1',
|
|
15
|
+
envFingerprint,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const attachedOperator = operatorWith(computeEnvFingerprint(environment))
|
|
20
|
+
|
|
21
|
+
// The pull and the operator-attach route share this decision. It is asserted directly because a
|
|
22
|
+
// second caller now depends on it, and because the `enabled` term used to be written out at each
|
|
23
|
+
// call site, where one copy lost it.
|
|
24
|
+
describe('evaluateEnvironmentReadiness', () => {
|
|
25
|
+
it.each([
|
|
26
|
+
['nothing configured', null, false, false, 'environment_not_ready'],
|
|
27
|
+
['configured but switched off', environment, false, true, 'integration_disabled'],
|
|
28
|
+
['configured, on, never checked healthy', environment, true, false, 'environment_not_ready'],
|
|
29
|
+
['configured, on and healthy', environment, true, true, null],
|
|
30
|
+
] as const)('%s', (_name, env, integrationEnabled, environmentHealthy, expected) => {
|
|
31
|
+
const readiness = evaluateEnvironmentReadiness({ environment: env, integrationEnabled, environmentHealthy })
|
|
32
|
+
expect(readiness.blocker).toBe(expected)
|
|
33
|
+
expect(readiness.ready).toBe(expected === null)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('evaluatePullReadiness', () => {
|
|
38
|
+
it('clears the pull when the environment is healthy and an operator is attached', () => {
|
|
39
|
+
expect(
|
|
40
|
+
evaluatePullReadiness({ environment, integrationEnabled: true, environmentHealthy: true, operator: attachedOperator }),
|
|
41
|
+
).toEqual({
|
|
42
|
+
environmentReady: true,
|
|
43
|
+
operatorAttached: true,
|
|
44
|
+
envDrift: false,
|
|
45
|
+
blocker: null,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('blocks on a missing environment', () => {
|
|
50
|
+
const readiness = evaluatePullReadiness({ environment: null, integrationEnabled: true, environmentHealthy: false, operator: null })
|
|
51
|
+
expect(readiness.blocker).toBe('environment_not_ready')
|
|
52
|
+
expect(readiness.environmentReady).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('blocks when the environment was never checked as healthy', () => {
|
|
56
|
+
const readiness = evaluatePullReadiness({ environment, integrationEnabled: true, environmentHealthy: false, operator: attachedOperator })
|
|
57
|
+
expect(readiness.blocker).toBe('environment_not_ready')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// A disabled integration passes every other check, so without its own blocker the UI would
|
|
61
|
+
// tell the user to re-run a health check that cannot change the outcome.
|
|
62
|
+
it('blocks a disabled integration even when everything else is ready', () => {
|
|
63
|
+
const readiness = evaluatePullReadiness({
|
|
64
|
+
environment,
|
|
65
|
+
integrationEnabled: false,
|
|
66
|
+
environmentHealthy: true,
|
|
67
|
+
operator: attachedOperator,
|
|
68
|
+
})
|
|
69
|
+
expect(readiness.blocker).toBe('integration_disabled')
|
|
70
|
+
expect(readiness.environmentReady).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// A fresh install has no integration row, and a missing row reads as disabled. Reporting that
|
|
74
|
+
// as the blocker would tell the user to enable an integration they have not configured yet,
|
|
75
|
+
// so the missing environment has to win while there is nothing to switch on.
|
|
76
|
+
it('reports the missing environment, not the switch, before anything is configured', () => {
|
|
77
|
+
const readiness = evaluatePullReadiness({
|
|
78
|
+
environment: null,
|
|
79
|
+
integrationEnabled: false,
|
|
80
|
+
environmentHealthy: false,
|
|
81
|
+
operator: null,
|
|
82
|
+
})
|
|
83
|
+
expect(readiness.blocker).toBe('environment_not_ready')
|
|
84
|
+
expect(readiness.environmentReady).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('blocks when no operator is attached', () => {
|
|
88
|
+
const readiness = evaluatePullReadiness({ environment, integrationEnabled: true, environmentHealthy: true, operator: null })
|
|
89
|
+
expect(readiness.blocker).toBe('operator_missing')
|
|
90
|
+
expect(readiness.operatorAttached).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('blocks when the environment drifted after the operator was attached', () => {
|
|
94
|
+
const readiness = evaluatePullReadiness({
|
|
95
|
+
environment,
|
|
96
|
+
integrationEnabled: true,
|
|
97
|
+
environmentHealthy: true,
|
|
98
|
+
operator: operatorWith('stale-fingerprint'),
|
|
99
|
+
})
|
|
100
|
+
expect(readiness.blocker).toBe('environment_drift')
|
|
101
|
+
expect(readiness.envDrift).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('reports an unready environment before a missing operator', () => {
|
|
105
|
+
const readiness = evaluatePullReadiness({ environment: null, integrationEnabled: true, environmentHealthy: false, operator: null })
|
|
106
|
+
expect(readiness.blocker).toBe('environment_not_ready')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('maps blockers to the section the UI highlights', () => {
|
|
110
|
+
expect(blockerSection('integration_disabled')).toBe('environment')
|
|
111
|
+
expect(blockerSection('environment_not_ready')).toBe('environment')
|
|
112
|
+
expect(blockerSection('operator_missing')).toBe('operator')
|
|
113
|
+
expect(blockerSection('environment_drift')).toBe('operator')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// The pull dialog once worded a switched-off integration as an unconfigured environment, so the
|
|
118
|
+
// server told the user to enable the integration and the screen told them to enter credentials
|
|
119
|
+
// they had already entered. Section mapping above was right; the copy was not.
|
|
120
|
+
describe('PULL_BLOCKER_COPY', () => {
|
|
121
|
+
const blockers: PullBlocker[] = [
|
|
122
|
+
'integration_disabled',
|
|
123
|
+
'environment_not_ready',
|
|
124
|
+
'operator_missing',
|
|
125
|
+
'environment_drift',
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
it('gives every blocker its own wording', () => {
|
|
129
|
+
const keys = blockers.map((blocker) => PULL_BLOCKER_COPY[blocker].key)
|
|
130
|
+
expect(new Set(keys).size).toBe(blockers.length)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('words a disabled integration as a disabled integration', () => {
|
|
134
|
+
expect(PULL_BLOCKER_COPY.integration_disabled.key).toBe('tillio.errors.integrationDisabled')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('ships an English string for every key it names', () => {
|
|
138
|
+
const catalog = englishCatalog as Record<string, string>
|
|
139
|
+
for (const blocker of blockers) {
|
|
140
|
+
expect(catalog[PULL_BLOCKER_COPY[blocker].key]).toBeTruthy()
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { POST } from '../api/pull/route'
|
|
2
|
+
|
|
3
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({ getAuthFromRequest: jest.fn() }))
|
|
4
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({ createRequestContainer: jest.fn() }))
|
|
5
|
+
jest.mock('@open-mercato/shared/lib/crud/route-mutation-guard', () => ({ runRouteMutationGuards: jest.fn() }))
|
|
6
|
+
jest.mock('../lib/pull-job', () => ({
|
|
7
|
+
...jest.requireActual('../lib/pull-job'),
|
|
8
|
+
resolvePullContext: jest.fn(),
|
|
9
|
+
}))
|
|
10
|
+
jest.mock('../lib/queue', () => ({
|
|
11
|
+
...jest.requireActual('../lib/queue'),
|
|
12
|
+
getTillioQueue: jest.fn(),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const { getAuthFromRequest } = jest.requireMock('@open-mercato/shared/lib/auth/server')
|
|
16
|
+
const { createRequestContainer } = jest.requireMock('@open-mercato/shared/lib/di/container')
|
|
17
|
+
const { runRouteMutationGuards } = jest.requireMock('@open-mercato/shared/lib/crud/route-mutation-guard')
|
|
18
|
+
const { resolvePullContext } = jest.requireMock('../lib/pull-job')
|
|
19
|
+
const { getTillioQueue } = jest.requireMock('../lib/queue')
|
|
20
|
+
|
|
21
|
+
const auth = { sub: 'user-1', tenantId: 'tn', orgId: 'org' }
|
|
22
|
+
|
|
23
|
+
function request(body: unknown): Request {
|
|
24
|
+
return new Request('http://localhost/api/tillio/pull', { method: 'POST', body: JSON.stringify(body) })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The route takes an advisory lock inside a transaction; the stub keeps the section running
|
|
28
|
+
// without a database, which is the only thing these cases need from it.
|
|
29
|
+
function createEm() {
|
|
30
|
+
return {
|
|
31
|
+
transactional: async <T>(run: (tx: unknown) => Promise<T>): Promise<T> =>
|
|
32
|
+
run({ getConnection: () => ({ execute: async () => undefined }) }),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createProgressService(overrides: Record<string, jest.Mock> = {}) {
|
|
37
|
+
return {
|
|
38
|
+
getActiveJobs: jest.fn().mockResolvedValue([]),
|
|
39
|
+
createJob: jest.fn().mockResolvedValue({ id: 'job-1' }),
|
|
40
|
+
failJob: jest.fn().mockResolvedValue(undefined),
|
|
41
|
+
...overrides,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function wire(progressService: ReturnType<typeof createProgressService>) {
|
|
46
|
+
createRequestContainer.mockResolvedValue({
|
|
47
|
+
resolve: (name: string) => {
|
|
48
|
+
if (name === 'progressService') return progressService
|
|
49
|
+
if (name === 'em') return createEm()
|
|
50
|
+
return {}
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
jest.clearAllMocks()
|
|
57
|
+
getAuthFromRequest.mockResolvedValue(auth)
|
|
58
|
+
resolvePullContext.mockResolvedValue({ readiness: { blocker: null }, environment: {}, operator: {} })
|
|
59
|
+
runRouteMutationGuards.mockResolvedValue({ ok: true, runAfterSuccess: jest.fn().mockResolvedValue(undefined) })
|
|
60
|
+
getTillioQueue.mockReturnValue({ enqueue: jest.fn().mockResolvedValue(undefined) })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('POST /api/tillio/pull', () => {
|
|
64
|
+
it('queues the pull and answers 202 with the progress job id', async () => {
|
|
65
|
+
const progressService = createProgressService()
|
|
66
|
+
wire(progressService)
|
|
67
|
+
const enqueue = jest.fn().mockResolvedValue(undefined)
|
|
68
|
+
getTillioQueue.mockReturnValue({ enqueue })
|
|
69
|
+
|
|
70
|
+
const response = await POST(request({ from: '2026-08-01', to: '2026-08-02' }))
|
|
71
|
+
|
|
72
|
+
expect(response.status).toBe(202)
|
|
73
|
+
await expect(response.json()).resolves.toMatchObject({ ok: true, progressJobId: 'job-1' })
|
|
74
|
+
expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ from: '2026-08-01', to: '2026-08-02' }))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('runs the pull on the range a guard rewrote, not the one the caller sent', async () => {
|
|
78
|
+
const progressService = createProgressService()
|
|
79
|
+
wire(progressService)
|
|
80
|
+
const enqueue = jest.fn().mockResolvedValue(undefined)
|
|
81
|
+
getTillioQueue.mockReturnValue({ enqueue })
|
|
82
|
+
runRouteMutationGuards.mockResolvedValue({
|
|
83
|
+
ok: true,
|
|
84
|
+
modifiedPayload: { to: '2026-08-01' },
|
|
85
|
+
runAfterSuccess: jest.fn().mockResolvedValue(undefined),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const response = await POST(request({ from: '2026-08-01', to: '2026-08-31' }))
|
|
89
|
+
|
|
90
|
+
expect(response.status).toBe(202)
|
|
91
|
+
expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ to: '2026-08-01' }))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns the guard response and creates nothing when a guard blocks the pull', async () => {
|
|
95
|
+
const progressService = createProgressService()
|
|
96
|
+
wire(progressService)
|
|
97
|
+
runRouteMutationGuards.mockResolvedValue({
|
|
98
|
+
ok: false,
|
|
99
|
+
response: Response.json({ ok: false, error: 'blocked' }, { status: 422 }),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const response = await POST(request({ from: '2026-08-01', to: '2026-08-02' }))
|
|
103
|
+
|
|
104
|
+
expect(response.status).toBe(422)
|
|
105
|
+
expect(progressService.createJob).not.toHaveBeenCalled()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('refuses a second pull while one is already running', async () => {
|
|
109
|
+
const progressService = createProgressService({
|
|
110
|
+
getActiveJobs: jest.fn().mockResolvedValue([{ jobType: 'tillio.calls.pull' }]),
|
|
111
|
+
})
|
|
112
|
+
wire(progressService)
|
|
113
|
+
|
|
114
|
+
const response = await POST(request({ from: '2026-08-01', to: '2026-08-02' }))
|
|
115
|
+
|
|
116
|
+
expect(response.status).toBe(429)
|
|
117
|
+
await expect(response.json()).resolves.toMatchObject({ code: 'pull_already_running' })
|
|
118
|
+
expect(progressService.createJob).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('fails the job it just created when the queue rejects the handover', async () => {
|
|
122
|
+
const progressService = createProgressService()
|
|
123
|
+
wire(progressService)
|
|
124
|
+
getTillioQueue.mockReturnValue({ enqueue: jest.fn().mockRejectedValue(new Error('queue down')) })
|
|
125
|
+
|
|
126
|
+
const response = await POST(request({ from: '2026-08-01', to: '2026-08-02' }))
|
|
127
|
+
|
|
128
|
+
expect(response.status).toBe(500)
|
|
129
|
+
// Left pending, the orphan would answer 429 to every later pull for this scope.
|
|
130
|
+
expect(progressService.failJob).toHaveBeenCalledWith('job-1', expect.anything(), expect.anything())
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('refuses to queue a pull for a disabled integration', async () => {
|
|
134
|
+
const progressService = createProgressService()
|
|
135
|
+
wire(progressService)
|
|
136
|
+
resolvePullContext.mockResolvedValue({
|
|
137
|
+
readiness: { blocker: 'integration_disabled' },
|
|
138
|
+
environment: {},
|
|
139
|
+
operator: {},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const response = await POST(request({ from: '2026-08-01', to: '2026-08-02' }))
|
|
143
|
+
|
|
144
|
+
expect(response.status).toBe(409)
|
|
145
|
+
await expect(response.json()).resolves.toMatchObject({ code: 'integration_disabled', section: 'environment' })
|
|
146
|
+
expect(progressService.createJob).not.toHaveBeenCalled()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { formatTillioTimestamp, isValidTimeZone, zonedDayEnd, zonedDayStart } from '../lib/tz'
|
|
2
|
+
|
|
3
|
+
describe('isValidTimeZone', () => {
|
|
4
|
+
it('accepts IANA zones and rejects anything Intl cannot resolve', () => {
|
|
5
|
+
expect(isValidTimeZone('Europe/Warsaw')).toBe(true)
|
|
6
|
+
expect(isValidTimeZone('America/New_York')).toBe(true)
|
|
7
|
+
expect(isValidTimeZone('UTC')).toBe(true)
|
|
8
|
+
// Intl resolves fixed-offset zones too, so they pass; only names it cannot resolve fail.
|
|
9
|
+
expect(isValidTimeZone('+02:00')).toBe(true)
|
|
10
|
+
expect(isValidTimeZone('Europe/Atlantis')).toBe(false)
|
|
11
|
+
expect(isValidTimeZone('Warsaw')).toBe(false)
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('a configured zone', () => {
|
|
16
|
+
it('moves the day boundaries with it', () => {
|
|
17
|
+
expect(zonedDayStart('2026-06-11', 'UTC').toISOString()).toBe('2026-06-11T00:00:00.000Z')
|
|
18
|
+
expect(zonedDayEnd('2026-06-11', 'UTC').toISOString()).toBe('2026-06-11T23:59:00.000Z')
|
|
19
|
+
expect(zonedDayStart('2026-06-11', 'America/New_York').toISOString()).toBe('2026-06-11T04:00:00.000Z')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('tillio timezone helpers', () => {
|
|
24
|
+
it('anchors a day to Europe/Warsaw wall-clock boundaries', () => {
|
|
25
|
+
expect(zonedDayStart('2026-06-11').toISOString()).toBe('2026-06-10T22:00:00.000Z')
|
|
26
|
+
expect(zonedDayEnd('2026-06-11').toISOString()).toBe('2026-06-11T21:59:00.000Z')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('handles the spring DST switch, where a day starts at +01:00 and ends at +02:00', () => {
|
|
30
|
+
expect(zonedDayStart('2026-03-29').toISOString()).toBe('2026-03-28T23:00:00.000Z')
|
|
31
|
+
expect(zonedDayEnd('2026-03-29').toISOString()).toBe('2026-03-29T21:59:00.000Z')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('handles the autumn DST switch, where a day starts at +02:00 and ends at +01:00', () => {
|
|
35
|
+
expect(zonedDayStart('2026-10-25').toISOString()).toBe('2026-10-24T22:00:00.000Z')
|
|
36
|
+
expect(zonedDayEnd('2026-10-25').toISOString()).toBe('2026-10-25T22:59:00.000Z')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('round-trips day boundaries back to the wall-clock format Tillio expects', () => {
|
|
40
|
+
for (const day of ['2026-01-15', '2026-03-29', '2026-06-11', '2026-10-25', '2026-12-31']) {
|
|
41
|
+
expect(formatTillioTimestamp(zonedDayStart(day))).toBe(`${day} 00:00`)
|
|
42
|
+
expect(formatTillioTimestamp(zonedDayEnd(day))).toBe(`${day} 23:59`)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('formats an instant using the Warsaw wall clock, not the host timezone', () => {
|
|
47
|
+
expect(formatTillioTimestamp(new Date('2026-04-11T10:47:28.000Z'))).toBe('2026-04-11 12:47')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects a malformed day', () => {
|
|
51
|
+
expect(() => zonedDayStart('11-04-2026')).toThrow()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { assertPublicTillioApiUrl } from '../lib/url-guard'
|
|
2
|
+
|
|
3
|
+
describe('assertPublicTillioApiUrl', () => {
|
|
4
|
+
it('allows public https hosts', () => {
|
|
5
|
+
expect(() => assertPublicTillioApiUrl('https://x.example.com')).not.toThrow()
|
|
6
|
+
expect(() => assertPublicTillioApiUrl('https://api.tillio.io/v1')).not.toThrow()
|
|
7
|
+
expect(() => assertPublicTillioApiUrl('https://example.com:8443')).not.toThrow()
|
|
8
|
+
expect(() => assertPublicTillioApiUrl('https://8.8.8.8')).not.toThrow()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('rejects plaintext http even on a public host', () => {
|
|
12
|
+
expect(() => assertPublicTillioApiUrl('http://api.tillio.example.com')).toThrow(/https/)
|
|
13
|
+
expect(() => assertPublicTillioApiUrl('http://example.com:8443')).toThrow(/https/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('rejects non-http(s) schemes', () => {
|
|
17
|
+
expect(() => assertPublicTillioApiUrl('file:///etc/passwd')).toThrow()
|
|
18
|
+
expect(() => assertPublicTillioApiUrl('gopher://example.com')).toThrow()
|
|
19
|
+
expect(() => assertPublicTillioApiUrl('not a url')).toThrow()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// The private-host cases stay on https so they keep exercising the host checks rather
|
|
23
|
+
// than tripping the transport check first.
|
|
24
|
+
it('rejects loopback and localhost', () => {
|
|
25
|
+
expect(() => assertPublicTillioApiUrl('https://localhost:9000')).toThrow()
|
|
26
|
+
expect(() => assertPublicTillioApiUrl('https://api.localhost')).toThrow()
|
|
27
|
+
expect(() => assertPublicTillioApiUrl('https://127.0.0.1')).toThrow()
|
|
28
|
+
expect(() => assertPublicTillioApiUrl('https://[::1]')).toThrow()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('rejects RFC1918 private ranges', () => {
|
|
32
|
+
expect(() => assertPublicTillioApiUrl('https://10.0.0.5')).toThrow()
|
|
33
|
+
expect(() => assertPublicTillioApiUrl('https://172.16.0.1')).toThrow()
|
|
34
|
+
expect(() => assertPublicTillioApiUrl('https://192.168.1.1')).toThrow()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('rejects link-local / cloud metadata', () => {
|
|
38
|
+
expect(() => assertPublicTillioApiUrl('https://169.254.169.254')).toThrow()
|
|
39
|
+
expect(() => assertPublicTillioApiUrl('https://[fe80::1]')).toThrow()
|
|
40
|
+
expect(() => assertPublicTillioApiUrl('https://0.0.0.0')).toThrow()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('rejects ipv4-mapped ipv6 loopback', () => {
|
|
44
|
+
expect(() => assertPublicTillioApiUrl('https://[::ffff:127.0.0.1]')).toThrow()
|
|
45
|
+
})
|
|
46
|
+
})
|