@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
package/package.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-mercato/tillio",
|
|
3
|
+
"version": "0.7.1-develop.7176.1.d229567a50",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "node build.mjs",
|
|
9
|
+
"test": "jest --config jest.config.cjs",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./dist/index.js",
|
|
14
|
+
"./*.ts": {
|
|
15
|
+
"types": "./src/*.ts",
|
|
16
|
+
"default": "./dist/*.js"
|
|
17
|
+
},
|
|
18
|
+
"./*.tsx": {
|
|
19
|
+
"types": "./src/*.tsx",
|
|
20
|
+
"default": "./dist/*.js"
|
|
21
|
+
},
|
|
22
|
+
"./*.json": "./src/*.json",
|
|
23
|
+
"./*": {
|
|
24
|
+
"types": [
|
|
25
|
+
"./src/*.ts",
|
|
26
|
+
"./src/*.tsx"
|
|
27
|
+
],
|
|
28
|
+
"default": "./dist/*.js"
|
|
29
|
+
},
|
|
30
|
+
"./*/*.json": "./src/*/*.json",
|
|
31
|
+
"./*/*": {
|
|
32
|
+
"types": [
|
|
33
|
+
"./src/*/*.ts",
|
|
34
|
+
"./src/*/*.tsx"
|
|
35
|
+
],
|
|
36
|
+
"default": "./dist/*/*.js"
|
|
37
|
+
},
|
|
38
|
+
"./*/*/*.json": "./src/*/*/*.json",
|
|
39
|
+
"./*/*/*": {
|
|
40
|
+
"types": [
|
|
41
|
+
"./src/*/*/*.ts",
|
|
42
|
+
"./src/*/*/*.tsx"
|
|
43
|
+
],
|
|
44
|
+
"default": "./dist/*/*/*.js"
|
|
45
|
+
},
|
|
46
|
+
"./*/*/*/*.json": "./src/*/*/*/*.json",
|
|
47
|
+
"./*/*/*/*": {
|
|
48
|
+
"types": [
|
|
49
|
+
"./src/*/*/*/*.ts",
|
|
50
|
+
"./src/*/*/*/*.tsx"
|
|
51
|
+
],
|
|
52
|
+
"default": "./dist/*/*/*/*.js"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@open-mercato/core": "0.7.1-develop.7176.1.d229567a50",
|
|
57
|
+
"@open-mercato/queue": "0.7.1-develop.7176.1.d229567a50",
|
|
58
|
+
"@open-mercato/ui": "0.7.1-develop.7176.1.d229567a50"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@mikro-orm/postgresql": "^7.0.14",
|
|
62
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7176.1.d229567a50",
|
|
63
|
+
"@open-mercato/shared": "0.7.1-develop.7176.1.d229567a50",
|
|
64
|
+
"react": "^19.0.0",
|
|
65
|
+
"react-dom": "^19.0.0",
|
|
66
|
+
"react-is": ">=18.0.0"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7176.1.d229567a50",
|
|
70
|
+
"@open-mercato/shared": "0.7.1-develop.7176.1.d229567a50",
|
|
71
|
+
"@types/jest": "^30.0.0",
|
|
72
|
+
"@types/react": "^19.2.18",
|
|
73
|
+
"@types/react-dom": "^19.2.5",
|
|
74
|
+
"esbuild": "^0.28.1",
|
|
75
|
+
"glob": "^13.0.6",
|
|
76
|
+
"jest": "^30.4.2",
|
|
77
|
+
"react": "19.2.8",
|
|
78
|
+
"react-dom": "19.2.8",
|
|
79
|
+
"react-is": "^19.2.8",
|
|
80
|
+
"ts-jest": "^29.4.11",
|
|
81
|
+
"typescript": "^6.0.3"
|
|
82
|
+
},
|
|
83
|
+
"publishConfig": {
|
|
84
|
+
"access": "public"
|
|
85
|
+
},
|
|
86
|
+
"repository": {
|
|
87
|
+
"type": "git",
|
|
88
|
+
"url": "https://github.com/open-mercato/open-mercato",
|
|
89
|
+
"directory": "packages/tillio"
|
|
90
|
+
},
|
|
91
|
+
"stableVersion": "0.7.0"
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { metadata } from './modules/tillio/index'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
+
|
|
5
|
+
type ReadinessBody = {
|
|
6
|
+
ok?: boolean
|
|
7
|
+
environmentReady?: boolean
|
|
8
|
+
operatorAttached?: boolean
|
|
9
|
+
envDrift?: boolean
|
|
10
|
+
blocker?: string | null
|
|
11
|
+
operatorId?: string | null
|
|
12
|
+
plugin?: string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* TC-PHONE-001 — the pull readiness probe reports an unconfigured Tillio environment.
|
|
17
|
+
*
|
|
18
|
+
* GET /api/tillio/pull only reads config out of the database; it never calls Tillio.
|
|
19
|
+
* On a fresh environment no Tillio credentials exist, so readiness must report the
|
|
20
|
+
* `environment_not_ready` blocker rather than erroring. This asserts the module's own
|
|
21
|
+
* readiness contract — the blocker precedence rules themselves are unit-tested in
|
|
22
|
+
* packages/tillio/src/modules/tillio/__tests__/pull-readiness.test.ts.
|
|
23
|
+
*/
|
|
24
|
+
test.describe('TC-PHONE-001: Tillio pull readiness', () => {
|
|
25
|
+
test('reports environment_not_ready when Tillio is not configured', async ({ request }) => {
|
|
26
|
+
const token = await getAuthToken(request, 'superadmin')
|
|
27
|
+
const response = await apiRequest(request, 'GET', '/api/tillio/pull', { token })
|
|
28
|
+
|
|
29
|
+
expect(response.status(), 'the readiness probe should be reachable').toBe(200)
|
|
30
|
+
|
|
31
|
+
const body = await readJsonSafe<ReadinessBody>(response)
|
|
32
|
+
expect(body?.ok, 'readiness responds ok even when blocked').toBe(true)
|
|
33
|
+
expect(body?.environmentReady, 'no Tillio credentials means the environment is not ready').toBe(false)
|
|
34
|
+
expect(body?.operatorAttached, 'no operator can be attached without an environment').toBe(false)
|
|
35
|
+
expect(body?.blocker, 'the environment blocker should be reported').toBe('environment_not_ready')
|
|
36
|
+
expect(body?.operatorId, 'no operator id without an operator').toBeNull()
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
+
|
|
5
|
+
type BlockedBody = { ok?: boolean; code?: string; section?: string; message?: string }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* TC-PHONE-002 — the pull refuses to run before Tillio is configured.
|
|
9
|
+
*
|
|
10
|
+
* The readiness gate returns 409 before `tillioAdapter.fetchCalls` is ever reached, so
|
|
11
|
+
* this exercises the real POST path without any network traffic to Tillio and without
|
|
12
|
+
* ingesting anything. That ordering is the point of the test: a regression that moved
|
|
13
|
+
* the gate below the fetch would turn an unconfigured tenant into a live provider call.
|
|
14
|
+
*
|
|
15
|
+
* A pull against a real Tillio environment needs Ringostat credentials and is therefore
|
|
16
|
+
* a deliberate manual check, not an automated one.
|
|
17
|
+
*/
|
|
18
|
+
test.describe('TC-PHONE-002: Tillio pull blocked before reaching the provider', () => {
|
|
19
|
+
test('returns a structured 409 when the environment is not configured', async ({ request }) => {
|
|
20
|
+
const token = await getAuthToken(request, 'superadmin')
|
|
21
|
+
const response = await apiRequest(request, 'POST', '/api/tillio/pull', {
|
|
22
|
+
token,
|
|
23
|
+
data: { from: '2026-03-01', to: '2026-03-02' },
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
expect(response.status(), 'an unconfigured environment must block the pull').toBe(409)
|
|
27
|
+
|
|
28
|
+
const body = await readJsonSafe<BlockedBody>(response)
|
|
29
|
+
expect(body?.ok).toBe(false)
|
|
30
|
+
expect(body?.code, 'the blocker code drives which settings section the UI opens').toBe('environment_not_ready')
|
|
31
|
+
expect(body?.section, 'environment blockers route to the environment section').toBe('environment')
|
|
32
|
+
expect(body?.message, 'the blocker carries an actionable message').toBeTruthy()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('rejects a reversed day range before touching readiness', async ({ request }) => {
|
|
36
|
+
const token = await getAuthToken(request, 'superadmin')
|
|
37
|
+
const response = await apiRequest(request, 'POST', '/api/tillio/pull', {
|
|
38
|
+
token,
|
|
39
|
+
data: { from: '2026-03-02', to: '2026-03-01' },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(response.status(), 'from must not be after to').toBe(400)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
+
|
|
5
|
+
type OperatorsBody = {
|
|
6
|
+
ok?: boolean
|
|
7
|
+
environmentReady?: boolean
|
|
8
|
+
tenantSystemId?: string | null
|
|
9
|
+
supportedPlugins?: string[]
|
|
10
|
+
operators?: Array<{ id?: string; plugin?: string; stale?: boolean }>
|
|
11
|
+
defaultOperatorId?: string | null
|
|
12
|
+
envDrift?: boolean
|
|
13
|
+
}
|
|
14
|
+
type BlockedBody = { ok?: boolean; code?: string; section?: string; message?: string }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* TC-PHONE-003 — the operator routes answer correctly before Tillio is configured.
|
|
18
|
+
*
|
|
19
|
+
* Attaching a real operator calls Tillio (`addConfig`) and needs a Ringostat key, so the
|
|
20
|
+
* live path stays a manual check. Everything asserted here stops before the network:
|
|
21
|
+
* the listing reads credentials out of the database, the attach is refused by the
|
|
22
|
+
* environment gate ahead of `attachOperator`, and detaching an unknown id returns early
|
|
23
|
+
* without resolving an environment at all.
|
|
24
|
+
*/
|
|
25
|
+
test.describe('TC-PHONE-003: Tillio operators', () => {
|
|
26
|
+
test('lists an empty operator set on an unconfigured environment', async ({ request }) => {
|
|
27
|
+
const token = await getAuthToken(request, 'superadmin')
|
|
28
|
+
const response = await apiRequest(request, 'GET', '/api/tillio/operators', { token })
|
|
29
|
+
|
|
30
|
+
expect(response.status(), 'the operator listing should be reachable').toBe(200)
|
|
31
|
+
|
|
32
|
+
const body = await readJsonSafe<OperatorsBody>(response)
|
|
33
|
+
expect(body?.ok).toBe(true)
|
|
34
|
+
expect(body?.environmentReady, 'no credentials means the environment is not ready').toBe(false)
|
|
35
|
+
expect(body?.operators, 'no operators can exist without an environment').toEqual([])
|
|
36
|
+
expect(body?.defaultOperatorId).toBeNull()
|
|
37
|
+
expect(body?.tenantSystemId).toBeNull()
|
|
38
|
+
expect(body?.envDrift, 'drift is meaningless with no operators attached').toBe(false)
|
|
39
|
+
expect(body?.supportedPlugins, 'the UI builds its plugin picker from this list').toEqual(['Ringostat'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('refuses to attach an operator before the environment is healthy', async ({ request }) => {
|
|
43
|
+
const token = await getAuthToken(request, 'superadmin')
|
|
44
|
+
const response = await apiRequest(request, 'POST', '/api/tillio/operators', {
|
|
45
|
+
token,
|
|
46
|
+
data: { plugin: 'Ringostat', config: { key: 'qa-tillio-003-never-used' } },
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(response.status(), 'attaching must be gated on a healthy environment').toBe(409)
|
|
50
|
+
|
|
51
|
+
const body = await readJsonSafe<BlockedBody>(response)
|
|
52
|
+
expect(body?.ok).toBe(false)
|
|
53
|
+
expect(body?.code).toBe('environment_not_ready')
|
|
54
|
+
expect(body?.section, 'the blocker routes the UI to the environment section').toBe('environment')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('rejects an unsupported plugin', async ({ request }) => {
|
|
58
|
+
const token = await getAuthToken(request, 'superadmin')
|
|
59
|
+
const response = await apiRequest(request, 'POST', '/api/tillio/operators', {
|
|
60
|
+
token,
|
|
61
|
+
data: { plugin: 'Twilio', config: { key: 'qa-tillio-003-never-used' } },
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
expect(response.status(), 'only Ringostat is a supported plugin today').toBe(400)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('detaching an unknown operator reports that nothing was detached', async ({ request }) => {
|
|
68
|
+
const token = await getAuthToken(request, 'superadmin')
|
|
69
|
+
const response = await apiRequest(request, 'DELETE', '/api/tillio/operators/qa-tillio-003-missing', { token })
|
|
70
|
+
|
|
71
|
+
expect(response.status()).toBe(200)
|
|
72
|
+
const body = await readJsonSafe<{ ok?: boolean; detached?: boolean }>(response)
|
|
73
|
+
expect(body?.ok).toBe(true)
|
|
74
|
+
expect(body?.detached, 'an unknown id detaches nothing rather than erroring').toBe(false)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { login } from '@open-mercato/core/helpers/integration/auth'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TC-PHONE-004 — the Pull widget reaches the hub's call list toolbar.
|
|
6
|
+
*
|
|
7
|
+
* This module injects `tillio.injection.pull-calls` into `data-table:phone_calls.calls:toolbar`,
|
|
8
|
+
* a spot owned by phone_calls in another package. Nothing in either package fails to compile if
|
|
9
|
+
* that wiring breaks — the button simply stops rendering — so the injection is asserted here.
|
|
10
|
+
*
|
|
11
|
+
* It lives in the provider package on purpose: phone_calls must keep passing with tillio
|
|
12
|
+
* disabled, so its own page spec cannot assert a Tillio button.
|
|
13
|
+
*/
|
|
14
|
+
test.describe('TC-PHONE-004: Tillio pull widget injection', () => {
|
|
15
|
+
test('renders the pull action on the phone calls list', async ({ page }) => {
|
|
16
|
+
await login(page, 'admin')
|
|
17
|
+
await page.goto('/backend/phone_calls')
|
|
18
|
+
|
|
19
|
+
await expect(page.getByRole('heading', { name: 'Phone Calls' })).toBeVisible()
|
|
20
|
+
await expect(page.getByRole('button', { name: 'Pull calls' })).toBeVisible()
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { login } from '@open-mercato/core/helpers/integration/auth'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TC-PHONE-005 — the operator configuration tab reaches the Tillio integration page.
|
|
6
|
+
*
|
|
7
|
+
* `tillio.injection.operators` is injected as a tab into this integration's detail spot,
|
|
8
|
+
* which the integrations module renders from the `IntegrationDefinition`. The registration
|
|
9
|
+
* chain (definition -> spot id -> injection table) has no compile-time link to the page, so
|
|
10
|
+
* a break would silently drop the tab.
|
|
11
|
+
*
|
|
12
|
+
* The page opens on Credentials, so the tab is activated before asserting its body. With no
|
|
13
|
+
* credentials stored the widget must guide the user to configure them instead of offering an
|
|
14
|
+
* operator form — that guidance is the state a fresh tenant actually sees.
|
|
15
|
+
*/
|
|
16
|
+
test.describe('TC-PHONE-005: Tillio operators widget injection', () => {
|
|
17
|
+
test('renders the operator configuration tab with not-ready guidance', async ({ page }) => {
|
|
18
|
+
await login(page, 'admin')
|
|
19
|
+
await page.goto('/backend/integrations/tillio')
|
|
20
|
+
|
|
21
|
+
await page.getByRole('tab', { name: 'Operator configuration' }).click()
|
|
22
|
+
|
|
23
|
+
await expect(
|
|
24
|
+
page.getByText('Save the Tillio API URL and key in the Credentials tab, then run the health Check before attaching an operator.'),
|
|
25
|
+
).toBeVisible()
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createTillioAdapter } from '../lib/adapter'
|
|
2
|
+
|
|
3
|
+
const credentials = {
|
|
4
|
+
apiUrl: 'https://x.example.com',
|
|
5
|
+
apiKey: 'k',
|
|
6
|
+
tenantSystemId: 'OM-abc',
|
|
7
|
+
operator: {
|
|
8
|
+
id: 'ringostat-1',
|
|
9
|
+
plugin: 'Ringostat' as const,
|
|
10
|
+
token: 'tok',
|
|
11
|
+
tenantDomain: 'app.example.com/OM-abc-ringostat-1',
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const scope = { organizationId: 'org', tenantId: 'ten' }
|
|
16
|
+
|
|
17
|
+
function call(id: string) {
|
|
18
|
+
return { id, date: '2026-04-11T12:47:28+0200', type: 'IN', caller: '48111', destination: '48222', status: 'ANSWERED', billSec: '5' }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function page(calls: unknown[], pageNumber: number, pages: number): Response {
|
|
22
|
+
return new Response(
|
|
23
|
+
JSON.stringify({ calls, pagination: { total: String(calls.length * pages), page: String(pageNumber), pages: String(pages) } }),
|
|
24
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function queryOf(fetchCall: unknown[]): URLSearchParams {
|
|
29
|
+
return new URL(fetchCall[0] as string).searchParams
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('tillioAdapter.fetchCalls', () => {
|
|
33
|
+
const fetchMock = jest.fn()
|
|
34
|
+
// The client now validates and DNS-pins every outbound URL, so the test injects both
|
|
35
|
+
// seams instead of stubbing the global fetch.
|
|
36
|
+
const tillioAdapter = createTillioAdapter({
|
|
37
|
+
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
38
|
+
lookupHost: async () => [{ address: '93.184.216.34', family: 4 }],
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
fetchMock.mockReset()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('walks every page reported by pagination and reports no cursor at the end', async () => {
|
|
46
|
+
fetchMock
|
|
47
|
+
.mockResolvedValueOnce(page([call('a')], 1, 3))
|
|
48
|
+
.mockResolvedValueOnce(page([call('b')], 2, 3))
|
|
49
|
+
.mockResolvedValueOnce(page([call('c')], 3, 3))
|
|
50
|
+
|
|
51
|
+
const batch = await tillioAdapter.fetchCalls({ credentials, scope })
|
|
52
|
+
|
|
53
|
+
expect(fetchMock).toHaveBeenCalledTimes(3)
|
|
54
|
+
expect(batch.calls.map((entry) => entry.externalCallId)).toEqual(['a', 'b', 'c'])
|
|
55
|
+
expect(batch.nextCursor).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('sends the Warsaw wall-clock window and the operator token', async () => {
|
|
59
|
+
fetchMock.mockResolvedValue(page([call('a')], 1, 1))
|
|
60
|
+
|
|
61
|
+
await tillioAdapter.fetchCalls({
|
|
62
|
+
credentials,
|
|
63
|
+
scope,
|
|
64
|
+
from: new Date('2026-06-10T22:00:00.000Z'),
|
|
65
|
+
to: new Date('2026-06-11T21:59:00.000Z'),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const query = queryOf(fetchMock.mock.calls[0])
|
|
69
|
+
expect(query.get('from')).toBe('2026-06-11 00:00')
|
|
70
|
+
expect(query.get('to')).toBe('2026-06-11 23:59')
|
|
71
|
+
expect(query.get('page')).toBe('1')
|
|
72
|
+
const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>
|
|
73
|
+
expect(headers['X-Token']).toBe('tok')
|
|
74
|
+
expect(headers['X-Tenant-Domain']).toBe('app.example.com/OM-abc-ringostat-1')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('sends the window in the configured zone instead of the default one', async () => {
|
|
78
|
+
fetchMock.mockResolvedValue(page([call('a')], 1, 1))
|
|
79
|
+
|
|
80
|
+
await tillioAdapter.fetchCalls({
|
|
81
|
+
credentials: { ...credentials, timeZone: 'UTC' },
|
|
82
|
+
scope,
|
|
83
|
+
from: new Date('2026-06-10T22:00:00.000Z'),
|
|
84
|
+
to: new Date('2026-06-11T21:59:00.000Z'),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const query = queryOf(fetchMock.mock.calls[0])
|
|
88
|
+
expect(query.get('from')).toBe('2026-06-10 22:00')
|
|
89
|
+
expect(query.get('to')).toBe('2026-06-11 21:59')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('stops at the batch limit but drains the current page first', async () => {
|
|
93
|
+
fetchMock
|
|
94
|
+
.mockResolvedValueOnce(page([call('a'), call('b')], 1, 4))
|
|
95
|
+
.mockResolvedValueOnce(page([call('c'), call('d')], 2, 4))
|
|
96
|
+
|
|
97
|
+
const batch = await tillioAdapter.fetchCalls({ credentials, scope, limit: 3 })
|
|
98
|
+
|
|
99
|
+
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
100
|
+
expect(batch.calls).toHaveLength(4)
|
|
101
|
+
expect(batch.nextCursor).toBe('3')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('resumes from the cursor', async () => {
|
|
105
|
+
fetchMock.mockResolvedValueOnce(page([call('c')], 2, 2))
|
|
106
|
+
|
|
107
|
+
const batch = await tillioAdapter.fetchCalls({ credentials, scope, cursor: '2' })
|
|
108
|
+
|
|
109
|
+
expect(queryOf(fetchMock.mock.calls[0]).get('page')).toBe('2')
|
|
110
|
+
expect(batch.calls.map((entry) => entry.externalCallId)).toEqual(['c'])
|
|
111
|
+
expect(batch.nextCursor).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// These three exits used to be indistinguishable from reaching the last page, which let a
|
|
115
|
+
// provider that ignores `page` pass off one page as the whole range.
|
|
116
|
+
it('reports the anomaly when the server ignores the requested page', async () => {
|
|
117
|
+
fetchMock.mockResolvedValue(page([call('a')], 1, 5))
|
|
118
|
+
|
|
119
|
+
const batch = await tillioAdapter.fetchCalls({ credentials, scope, cursor: '3' })
|
|
120
|
+
|
|
121
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
122
|
+
expect(batch.calls).toHaveLength(1)
|
|
123
|
+
expect(batch.nextCursor).toBeNull()
|
|
124
|
+
expect(batch.anomaly).toBe('page_not_echoed')
|
|
125
|
+
expect(batch.anomalyCursor).toBe('3')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('reports the anomaly on an empty page even when more pages are claimed', async () => {
|
|
129
|
+
fetchMock.mockResolvedValueOnce(page([], 1, 9))
|
|
130
|
+
|
|
131
|
+
const batch = await tillioAdapter.fetchCalls({ credentials, scope })
|
|
132
|
+
|
|
133
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
134
|
+
expect(batch.calls).toHaveLength(0)
|
|
135
|
+
expect(batch.nextCursor).toBeNull()
|
|
136
|
+
expect(batch.anomaly).toBe('empty_page')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('reports the anomaly when pagination claims zero pages', async () => {
|
|
140
|
+
fetchMock.mockResolvedValueOnce(page([call('a')], 1, 0))
|
|
141
|
+
|
|
142
|
+
const batch = await tillioAdapter.fetchCalls({ credentials, scope })
|
|
143
|
+
|
|
144
|
+
expect(batch.anomaly).toBe('invalid_page_count')
|
|
145
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
146
|
+
expect(batch.nextCursor).toBeNull()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createTillioClient, redactTillioUrl, type TillioClientDeps } from '../lib/client'
|
|
2
|
+
|
|
3
|
+
const env = { apiUrl: 'https://x.example.com', apiKey: 'k', tenantSystemId: 'OM-abc' }
|
|
4
|
+
|
|
5
|
+
const publicLookupHost: TillioClientDeps['lookupHost'] = async () => [{ address: '93.184.216.34', family: 4 }]
|
|
6
|
+
|
|
7
|
+
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
|
8
|
+
return new Response(JSON.stringify(body), {
|
|
9
|
+
status,
|
|
10
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function redirectResponse(status: number, location?: string): Response {
|
|
15
|
+
return new Response(null, {
|
|
16
|
+
status,
|
|
17
|
+
headers: location ? { location } : {},
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function headersOf(call: unknown[]): Record<string, string> {
|
|
22
|
+
return (call[1] as RequestInit).headers as Record<string, string>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('createTillioClient', () => {
|
|
26
|
+
const fetchMock = jest.fn()
|
|
27
|
+
|
|
28
|
+
function client(deps: TillioClientDeps = {}) {
|
|
29
|
+
return createTillioClient(env, {
|
|
30
|
+
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
31
|
+
lookupHost: publicLookupHost,
|
|
32
|
+
...deps,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
fetchMock.mockReset()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('sends the environment identity headers on getPlugins', async () => {
|
|
41
|
+
fetchMock.mockResolvedValue(jsonResponse({ plugins: [] }))
|
|
42
|
+
await client().getPlugins('test_connection')
|
|
43
|
+
const [url] = fetchMock.mock.calls[0]
|
|
44
|
+
expect(url).toBe('https://x.example.com/api/plugins')
|
|
45
|
+
const headers = headersOf(fetchMock.mock.calls[0])
|
|
46
|
+
expect(headers['X-Api-Key']).toBe('k')
|
|
47
|
+
expect(headers['X-System']).toBe('OM-abc')
|
|
48
|
+
expect(headers['X-Tenant']).toBe('OM-abc')
|
|
49
|
+
expect(headers['X-Tenant-Domain']).toBe('test_connection')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('throws TillioApiError on an error-in-200 body', async () => {
|
|
53
|
+
fetchMock.mockResolvedValue(jsonResponse({ error: 'unauthorized', message: 'Bad key' }))
|
|
54
|
+
await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ name: 'TillioApiError', detail: 'unauthorized' })
|
|
55
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('retries on 503 then succeeds', async () => {
|
|
59
|
+
fetchMock
|
|
60
|
+
.mockResolvedValueOnce(jsonResponse({}, 503))
|
|
61
|
+
.mockResolvedValueOnce(jsonResponse({ plugins: [] }))
|
|
62
|
+
await client().getPlugins('test_connection')
|
|
63
|
+
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('fails fast on a network error without retrying', async () => {
|
|
67
|
+
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'))
|
|
68
|
+
await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ detail: 'network' })
|
|
69
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('addConfig posts plugin+config with the operator tenant-domain and returns the token', async () => {
|
|
73
|
+
fetchMock.mockResolvedValue(jsonResponse({ token: 'tok' }))
|
|
74
|
+
const res = await client().addConfig('Ringostat', { key: 'rk' }, 'x.example.com/OM-abc-ringostat')
|
|
75
|
+
const [url, init] = fetchMock.mock.calls[0]
|
|
76
|
+
expect(url).toBe('https://x.example.com/api/config')
|
|
77
|
+
expect(JSON.parse((init as RequestInit).body as string)).toEqual({ plugin: 'Ringostat', config: { key: 'rk' } })
|
|
78
|
+
expect(headersOf(fetchMock.mock.calls[0])['X-Tenant-Domain']).toBe('x.example.com/OM-abc-ringostat')
|
|
79
|
+
expect(res.token).toBe('tok')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('outbound URL safety', () => {
|
|
83
|
+
it('refuses to build a client for a private API URL', () => {
|
|
84
|
+
expect(() => createTillioClient({ ...env, apiUrl: 'http://169.254.169.254' })).toThrow()
|
|
85
|
+
expect(() => createTillioClient({ ...env, apiUrl: 'http://10.0.0.5' })).toThrow()
|
|
86
|
+
expect(() => createTillioClient({ ...env, apiUrl: 'http://localhost:9000' })).toThrow()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('blocks a public hostname that resolves to a private address', async () => {
|
|
90
|
+
const lookupHost = jest.fn(async () => [{ address: '10.0.0.5', family: 4 }])
|
|
91
|
+
await expect(client({ lookupHost }).getPlugins('test_connection')).rejects.toMatchObject({
|
|
92
|
+
name: 'TillioApiError',
|
|
93
|
+
detail: 'private_ip_resolved',
|
|
94
|
+
})
|
|
95
|
+
expect(lookupHost).toHaveBeenCalledWith('x.example.com')
|
|
96
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('revalidates DNS on every request, so a rebind between calls is caught', async () => {
|
|
100
|
+
fetchMock.mockResolvedValue(jsonResponse({ plugins: [] }))
|
|
101
|
+
const lookupHost = jest.fn()
|
|
102
|
+
.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }])
|
|
103
|
+
.mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }])
|
|
104
|
+
const tillio = client({ lookupHost })
|
|
105
|
+
|
|
106
|
+
await tillio.getPlugins('test_connection')
|
|
107
|
+
await expect(tillio.getPlugins('test_connection')).rejects.toMatchObject({ detail: 'private_ip_resolved' })
|
|
108
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('follows a same-host redirect and revalidates the target', async () => {
|
|
112
|
+
const lookupHost = jest.fn(publicLookupHost)
|
|
113
|
+
fetchMock
|
|
114
|
+
.mockResolvedValueOnce(redirectResponse(302, 'https://x.example.com/api/plugins/v2'))
|
|
115
|
+
.mockResolvedValueOnce(jsonResponse({ plugins: [] }))
|
|
116
|
+
|
|
117
|
+
await client({ lookupHost }).getPlugins('test_connection')
|
|
118
|
+
|
|
119
|
+
expect(fetchMock.mock.calls[1][0]).toBe('https://x.example.com/api/plugins/v2')
|
|
120
|
+
expect(lookupHost).toHaveBeenCalledTimes(2)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('refuses a redirect that leaves the configured host', async () => {
|
|
124
|
+
fetchMock.mockResolvedValueOnce(redirectResponse(302, 'https://attacker.example.net/api/plugins'))
|
|
125
|
+
await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ detail: 'redirect' })
|
|
126
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('refuses a redirect pointing at a private address before it is followed', async () => {
|
|
130
|
+
const lookupHost = jest.fn(publicLookupHost)
|
|
131
|
+
fetchMock.mockResolvedValueOnce(redirectResponse(302, 'http://169.254.169.254/latest/meta-data'))
|
|
132
|
+
|
|
133
|
+
await expect(client({ lookupHost }).getPlugins('test_connection')).rejects.toMatchObject({ detail: 'redirect' })
|
|
134
|
+
|
|
135
|
+
// The target is never requested, and never even resolved.
|
|
136
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
137
|
+
expect(lookupHost).toHaveBeenCalledTimes(1)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('refuses a same-host redirect once that host resolves to a private address', async () => {
|
|
141
|
+
fetchMock
|
|
142
|
+
.mockResolvedValueOnce(redirectResponse(302, 'https://x.example.com/api/plugins/v2'))
|
|
143
|
+
.mockResolvedValueOnce(jsonResponse({ plugins: [] }))
|
|
144
|
+
const lookupHost = jest.fn()
|
|
145
|
+
.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }])
|
|
146
|
+
.mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }])
|
|
147
|
+
|
|
148
|
+
await expect(client({ lookupHost }).getPlugins('test_connection')).rejects.toMatchObject({
|
|
149
|
+
detail: 'private_ip_resolved',
|
|
150
|
+
})
|
|
151
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('refuses a redirect that downgrades https to http', async () => {
|
|
155
|
+
fetchMock.mockResolvedValueOnce(redirectResponse(301, 'http://x.example.com/api/plugins'))
|
|
156
|
+
await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ detail: 'redirect' })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('refuses a redirect without a Location header', async () => {
|
|
160
|
+
fetchMock.mockResolvedValueOnce(redirectResponse(302))
|
|
161
|
+
await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ detail: 'redirect' })
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('stops after too many redirects', async () => {
|
|
165
|
+
fetchMock.mockResolvedValue(redirectResponse(302, 'https://x.example.com/api/loop'))
|
|
166
|
+
await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ detail: 'redirect' })
|
|
167
|
+
expect(fetchMock).toHaveBeenCalledTimes(4)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('drops the body when a 303 turns the request into a GET', async () => {
|
|
171
|
+
fetchMock
|
|
172
|
+
.mockResolvedValueOnce(redirectResponse(303, 'https://x.example.com/api/config/result'))
|
|
173
|
+
.mockResolvedValueOnce(jsonResponse({ token: 'tok' }))
|
|
174
|
+
|
|
175
|
+
await client().addConfig('Ringostat', { key: 'rk' }, 'x.example.com/OM-abc-ringostat')
|
|
176
|
+
|
|
177
|
+
const [, followUpInit] = fetchMock.mock.calls[1]
|
|
178
|
+
expect((followUpInit as RequestInit).method).toBe('GET')
|
|
179
|
+
expect((followUpInit as RequestInit).body).toBeUndefined()
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('token redaction', () => {
|
|
184
|
+
it('keeps the token out of a network failure message', async () => {
|
|
185
|
+
fetchMock.mockRejectedValue(new Error('ECONNRESET'))
|
|
186
|
+
const promise = client().deleteConfig('Ringostat', 'super-secret-token', 'x.example.com/OM-abc-ringostat')
|
|
187
|
+
await expect(promise).rejects.toMatchObject({ detail: 'network' })
|
|
188
|
+
await expect(promise).rejects.toThrow(/token=\*\*\*/)
|
|
189
|
+
await expect(promise).rejects.not.toThrow(/super-secret-token/)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('masks only the token parameter', () => {
|
|
193
|
+
expect(redactTillioUrl('https://x.example.com/api/config?plugin=Ringostat&token=abc')).toBe(
|
|
194
|
+
'https://x.example.com/api/config?plugin=Ringostat&token=***',
|
|
195
|
+
)
|
|
196
|
+
expect(redactTillioUrl('https://x.example.com/api/call?page=2')).toBe('https://x.example.com/api/call?page=2')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('drops the whole query when the URL cannot be parsed', () => {
|
|
200
|
+
expect(redactTillioUrl('not a url?token=abc')).toBe('not a url')
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
})
|