@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.
Files changed (135) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/build.mjs +7 -0
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +7 -0
  5. package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js +17 -0
  6. package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js.map +7 -0
  7. package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js +27 -0
  8. package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js.map +7 -0
  9. package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js +47 -0
  10. package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js.map +7 -0
  11. package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js +11 -0
  12. package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js.map +7 -0
  13. package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js +13 -0
  14. package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js.map +7 -0
  15. package/dist/modules/tillio/acl.js +9 -0
  16. package/dist/modules/tillio/acl.js.map +7 -0
  17. package/dist/modules/tillio/api/operators/[id]/route.js +78 -0
  18. package/dist/modules/tillio/api/operators/[id]/route.js.map +7 -0
  19. package/dist/modules/tillio/api/operators/route.js +171 -0
  20. package/dist/modules/tillio/api/operators/route.js.map +7 -0
  21. package/dist/modules/tillio/api/pull/route.js +180 -0
  22. package/dist/modules/tillio/api/pull/route.js.map +7 -0
  23. package/dist/modules/tillio/cli.js +108 -0
  24. package/dist/modules/tillio/cli.js.map +7 -0
  25. package/dist/modules/tillio/di.js +16 -0
  26. package/dist/modules/tillio/di.js.map +7 -0
  27. package/dist/modules/tillio/index.js +12 -0
  28. package/dist/modules/tillio/index.js.map +7 -0
  29. package/dist/modules/tillio/integration.js +62 -0
  30. package/dist/modules/tillio/integration.js.map +7 -0
  31. package/dist/modules/tillio/lib/adapter.js +107 -0
  32. package/dist/modules/tillio/lib/adapter.js.map +7 -0
  33. package/dist/modules/tillio/lib/client.js +249 -0
  34. package/dist/modules/tillio/lib/client.js.map +7 -0
  35. package/dist/modules/tillio/lib/environment.js +34 -0
  36. package/dist/modules/tillio/lib/environment.js.map +7 -0
  37. package/dist/modules/tillio/lib/error-codes.js +59 -0
  38. package/dist/modules/tillio/lib/error-codes.js.map +7 -0
  39. package/dist/modules/tillio/lib/errors.js +12 -0
  40. package/dist/modules/tillio/lib/errors.js.map +7 -0
  41. package/dist/modules/tillio/lib/health.js +41 -0
  42. package/dist/modules/tillio/lib/health.js.map +7 -0
  43. package/dist/modules/tillio/lib/locking.js +18 -0
  44. package/dist/modules/tillio/lib/locking.js.map +7 -0
  45. package/dist/modules/tillio/lib/normalizer.js +134 -0
  46. package/dist/modules/tillio/lib/normalizer.js.map +7 -0
  47. package/dist/modules/tillio/lib/operators-store.js +62 -0
  48. package/dist/modules/tillio/lib/operators-store.js.map +7 -0
  49. package/dist/modules/tillio/lib/operators.js +168 -0
  50. package/dist/modules/tillio/lib/operators.js.map +7 -0
  51. package/dist/modules/tillio/lib/preset.js +122 -0
  52. package/dist/modules/tillio/lib/preset.js.map +7 -0
  53. package/dist/modules/tillio/lib/pull-job.js +265 -0
  54. package/dist/modules/tillio/lib/pull-job.js.map +7 -0
  55. package/dist/modules/tillio/lib/pull-readiness.js +36 -0
  56. package/dist/modules/tillio/lib/pull-readiness.js.map +7 -0
  57. package/dist/modules/tillio/lib/queue.js +22 -0
  58. package/dist/modules/tillio/lib/queue.js.map +7 -0
  59. package/dist/modules/tillio/lib/tz.js +85 -0
  60. package/dist/modules/tillio/lib/tz.js.map +7 -0
  61. package/dist/modules/tillio/lib/url-guard.js +24 -0
  62. package/dist/modules/tillio/lib/url-guard.js.map +7 -0
  63. package/dist/modules/tillio/setup.js +45 -0
  64. package/dist/modules/tillio/setup.js.map +7 -0
  65. package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js +208 -0
  66. package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js.map +7 -0
  67. package/dist/modules/tillio/widgets/injection/operators-config/widget.js +17 -0
  68. package/dist/modules/tillio/widgets/injection/operators-config/widget.js.map +7 -0
  69. package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js +148 -0
  70. package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js.map +7 -0
  71. package/dist/modules/tillio/widgets/injection/pull-calls/widget.js +17 -0
  72. package/dist/modules/tillio/widgets/injection/pull-calls/widget.js.map +7 -0
  73. package/dist/modules/tillio/widgets/injection-table.js +21 -0
  74. package/dist/modules/tillio/widgets/injection-table.js.map +7 -0
  75. package/dist/modules/tillio/workers/tillio-pull.js +17 -0
  76. package/dist/modules/tillio/workers/tillio-pull.js.map +7 -0
  77. package/jest.config.cjs +35 -0
  78. package/package.json +92 -0
  79. package/src/index.ts +1 -0
  80. package/src/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.ts +38 -0
  81. package/src/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.ts +44 -0
  82. package/src/modules/tillio/__integration__/TC-PHONE-003-operators.spec.ts +76 -0
  83. package/src/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.ts +22 -0
  84. package/src/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.ts +27 -0
  85. package/src/modules/tillio/__tests__/adapter-fetch-calls.test.ts +148 -0
  86. package/src/modules/tillio/__tests__/client.test.ts +203 -0
  87. package/src/modules/tillio/__tests__/health.test.ts +122 -0
  88. package/src/modules/tillio/__tests__/normalizer.test.ts +143 -0
  89. package/src/modules/tillio/__tests__/operators-routes.test.ts +100 -0
  90. package/src/modules/tillio/__tests__/operators-store.test.ts +104 -0
  91. package/src/modules/tillio/__tests__/operators.test.ts +250 -0
  92. package/src/modules/tillio/__tests__/preset.test.ts +337 -0
  93. package/src/modules/tillio/__tests__/pull-body.test.ts +43 -0
  94. package/src/modules/tillio/__tests__/pull-job.test.ts +250 -0
  95. package/src/modules/tillio/__tests__/pull-readiness.test.ts +143 -0
  96. package/src/modules/tillio/__tests__/pull-route.test.ts +148 -0
  97. package/src/modules/tillio/__tests__/tz.test.ts +53 -0
  98. package/src/modules/tillio/__tests__/url-guard.test.ts +46 -0
  99. package/src/modules/tillio/acl.ts +5 -0
  100. package/src/modules/tillio/api/operators/[id]/route.ts +90 -0
  101. package/src/modules/tillio/api/operators/route.ts +191 -0
  102. package/src/modules/tillio/api/pull/route.ts +213 -0
  103. package/src/modules/tillio/cli.ts +125 -0
  104. package/src/modules/tillio/di.ts +16 -0
  105. package/src/modules/tillio/i18n/de.json +73 -0
  106. package/src/modules/tillio/i18n/en.json +73 -0
  107. package/src/modules/tillio/i18n/es.json +73 -0
  108. package/src/modules/tillio/i18n/ko.json +73 -0
  109. package/src/modules/tillio/i18n/pl.json +73 -0
  110. package/src/modules/tillio/index.ts +8 -0
  111. package/src/modules/tillio/integration.ts +58 -0
  112. package/src/modules/tillio/lib/adapter.ts +135 -0
  113. package/src/modules/tillio/lib/client.ts +329 -0
  114. package/src/modules/tillio/lib/environment.ts +53 -0
  115. package/src/modules/tillio/lib/error-codes.ts +67 -0
  116. package/src/modules/tillio/lib/errors.ts +11 -0
  117. package/src/modules/tillio/lib/health.ts +57 -0
  118. package/src/modules/tillio/lib/locking.ts +30 -0
  119. package/src/modules/tillio/lib/normalizer.ts +184 -0
  120. package/src/modules/tillio/lib/operators-store.ts +77 -0
  121. package/src/modules/tillio/lib/operators.ts +269 -0
  122. package/src/modules/tillio/lib/preset.ts +226 -0
  123. package/src/modules/tillio/lib/pull-job.ts +390 -0
  124. package/src/modules/tillio/lib/pull-readiness.ts +77 -0
  125. package/src/modules/tillio/lib/queue.ts +33 -0
  126. package/src/modules/tillio/lib/tz.ts +121 -0
  127. package/src/modules/tillio/lib/url-guard.ts +34 -0
  128. package/src/modules/tillio/setup.ts +49 -0
  129. package/src/modules/tillio/widgets/injection/operators-config/widget.client.tsx +304 -0
  130. package/src/modules/tillio/widgets/injection/operators-config/widget.ts +16 -0
  131. package/src/modules/tillio/widgets/injection/pull-calls/widget.client.tsx +214 -0
  132. package/src/modules/tillio/widgets/injection/pull-calls/widget.ts +16 -0
  133. package/src/modules/tillio/widgets/injection-table.ts +19 -0
  134. package/src/modules/tillio/workers/tillio-pull.ts +18 -0
  135. package/tsconfig.json +9 -0
@@ -0,0 +1,122 @@
1
+ import { createTillioEnvironmentHealthCheck } from '../lib/health'
2
+ import { createTillioClient } from '../lib/client'
3
+ import { TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID } from '../lib/environment'
4
+
5
+ jest.mock('../lib/client', () => ({
6
+ createTillioClient: jest.fn(),
7
+ }))
8
+
9
+ const createTillioClientMock = createTillioClient as jest.MockedFunction<typeof createTillioClient>
10
+
11
+ const scope = { tenantId: 'tn', organizationId: 'org' }
12
+ const env = { apiUrl: 'https://x.example.com', apiKey: 'k' }
13
+
14
+ function mockPlugins(getPlugins = jest.fn().mockResolvedValue({ plugins: [] })) {
15
+ createTillioClientMock.mockReturnValue({ getPlugins } as unknown as ReturnType<typeof createTillioClient>)
16
+ return getPlugins
17
+ }
18
+
19
+ function fakeStore(initial: Record<string, Record<string, unknown> | null> = {}) {
20
+ const store: Record<string, Record<string, unknown> | null> = { ...initial }
21
+ return {
22
+ store,
23
+ service: {
24
+ getRaw: jest.fn(async (id: string) => store[id] ?? null),
25
+ save: jest.fn(async (id: string, credentials: Record<string, unknown>) => {
26
+ store[id] = credentials
27
+ }),
28
+ },
29
+ }
30
+ }
31
+
32
+ describe('createTillioEnvironmentHealthCheck.check', () => {
33
+ beforeEach(() => {
34
+ createTillioClientMock.mockReset()
35
+ })
36
+
37
+ it('generates and persists tenantSystemId when missing, then reports healthy', async () => {
38
+ const probe = mockPlugins()
39
+ const { service, store } = fakeStore()
40
+ const health = createTillioEnvironmentHealthCheck({ credentialsService: service })
41
+
42
+ const res = await health.check({ ...env }, scope)
43
+
44
+ expect(service.save).toHaveBeenCalledWith(
45
+ TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID,
46
+ { tenantSystemId: expect.stringMatching(/^OM-/) },
47
+ scope,
48
+ )
49
+ expect(store.tillio).toBeUndefined()
50
+ expect(probe).toHaveBeenCalledWith('test_connection')
51
+ expect(res.status).toBe('healthy')
52
+ })
53
+
54
+ it('reuses an existing tenantSystemId without re-saving', async () => {
55
+ mockPlugins()
56
+ const { service } = fakeStore({
57
+ [TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID]: { tenantSystemId: 'OM-existing' },
58
+ })
59
+ const health = createTillioEnvironmentHealthCheck({ credentialsService: service })
60
+
61
+ const res = await health.check({ ...env }, scope)
62
+
63
+ expect(service.save).not.toHaveBeenCalled()
64
+ expect(createTillioClientMock).toHaveBeenCalledWith(expect.objectContaining({ tenantSystemId: 'OM-existing' }))
65
+ expect(res.status).toBe('healthy')
66
+ })
67
+
68
+ it('adopts an identity left in the credentials record instead of minting a new one', async () => {
69
+ mockPlugins()
70
+ const { service } = fakeStore()
71
+ const health = createTillioEnvironmentHealthCheck({ credentialsService: service })
72
+
73
+ const res = await health.check({ ...env, tenantSystemId: 'OM-legacy' }, scope)
74
+
75
+ expect(service.save).toHaveBeenCalledWith(
76
+ TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID,
77
+ { tenantSystemId: 'OM-legacy' },
78
+ scope,
79
+ )
80
+ expect(createTillioClientMock).toHaveBeenCalledWith(expect.objectContaining({ tenantSystemId: 'OM-legacy' }))
81
+ expect(res.status).toBe('healthy')
82
+ })
83
+
84
+ it('keeps the identity when the credentials record is saved without it', async () => {
85
+ mockPlugins()
86
+ const { service } = fakeStore({
87
+ [TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID]: { tenantSystemId: 'OM-kept' },
88
+ })
89
+ const health = createTillioEnvironmentHealthCheck({ credentialsService: service })
90
+
91
+ // What the integration form writes back: the schema fields only.
92
+ const res = await health.check({ apiUrl: env.apiUrl, apiKey: 'rotated' }, scope)
93
+
94
+ expect(service.save).not.toHaveBeenCalled()
95
+ expect(createTillioClientMock).toHaveBeenCalledWith(expect.objectContaining({ tenantSystemId: 'OM-kept' }))
96
+ expect(res.status).toBe('healthy')
97
+ })
98
+
99
+ it('reports unhealthy when getPlugins fails', async () => {
100
+ mockPlugins(jest.fn().mockRejectedValue(new Error('401 unauthorized')))
101
+ const { service } = fakeStore({
102
+ [TILLIO_ENVIRONMENT_IDENTITY_INTEGRATION_ID]: { tenantSystemId: 'OM-x' },
103
+ })
104
+ const health = createTillioEnvironmentHealthCheck({ credentialsService: service })
105
+
106
+ const res = await health.check({ ...env }, scope)
107
+
108
+ expect(res.status).toBe('unhealthy')
109
+ expect(res.message).toContain('401')
110
+ })
111
+
112
+ it('reports unhealthy for an incomplete environment without touching Tillio', async () => {
113
+ const { service } = fakeStore()
114
+ const health = createTillioEnvironmentHealthCheck({ credentialsService: service })
115
+
116
+ const res = await health.check({ apiUrl: 'https://x.example.com' }, scope)
117
+
118
+ expect(res.status).toBe('unhealthy')
119
+ expect(service.save).not.toHaveBeenCalled()
120
+ expect(createTillioClientMock).not.toHaveBeenCalled()
121
+ })
122
+ })
@@ -0,0 +1,143 @@
1
+ import { mapStatus, normalizeTillioCall } from '../lib/normalizer'
2
+
3
+ const context = { operatorId: 'ringostat-1', plugin: 'Ringostat' as const }
4
+
5
+ const answeredCall = {
6
+ id: 'pl5_-id1235453.9876543',
7
+ date: '2026-05-26T14:07:23+0200',
8
+ type: 'OUT',
9
+ caller: '48579771838',
10
+ destination: '48726861925',
11
+ status: 'ANSWERED',
12
+ waitTime: '10',
13
+ billSec: '7',
14
+ extraFields: {
15
+ caller: '&quot;itrixcom_Kontakt&quot; &lt;itrixcom_Kontakt&gt;',
16
+ has_recording: '1',
17
+ recording: 'https://app.ringostat.com/recordings/pl5_-1779797243.25187642.wav?token=fajny_token',
18
+ call_card: 'https://app.ringostat.com/project/callcards/card/pl5_-1779797243.25187642/?project_id=projekt_id',
19
+ },
20
+ }
21
+
22
+ const missedCall = {
23
+ id: 'kz1_-1775904448.9517822',
24
+ date: '2026-04-11T12:47:28+0200',
25
+ type: 'IN',
26
+ caller: '48739109594',
27
+ destination: '48579771838',
28
+ status: 'NO FORWARD',
29
+ waitTime: '1',
30
+ billSec: '0',
31
+ extraFields: {},
32
+ }
33
+
34
+ describe('mapStatus', () => {
35
+ it('lets a billed call win over the textual status', () => {
36
+ expect(mapStatus('NO ANSWER', 12)).toBe('answered')
37
+ })
38
+
39
+ it('maps the answered family', () => {
40
+ for (const status of ['ANSWERED', 'PROPER', 'REPEATED', 'CONNECTED']) {
41
+ expect(mapStatus(status, 0)).toBe('answered')
42
+ }
43
+ })
44
+
45
+ it('maps the missed family', () => {
46
+ for (const status of ['NO ANSWER', 'NO FORWARD', 'VOICEMAIL', 'NO EXTENSION', 'BUSY']) {
47
+ expect(mapStatus(status, 0)).toBe('missed')
48
+ }
49
+ })
50
+
51
+ it('maps a call that never got set up to failed', () => {
52
+ expect(mapStatus('FAILED', 0)).toBe('failed')
53
+ })
54
+
55
+ it('falls back to unknown for statuses Tillio has not documented', () => {
56
+ expect(mapStatus('SOMETHING NEW', 0)).toBe('unknown')
57
+ expect(mapStatus(undefined, null)).toBe('unknown')
58
+ expect(mapStatus('', 0)).toBe('unknown')
59
+ })
60
+ })
61
+
62
+ describe('normalizeTillioCall', () => {
63
+ it('maps an answered Ringostat call end to end', () => {
64
+ const normalized = normalizeTillioCall(answeredCall, context)
65
+
66
+ expect(normalized.externalCallId).toBe('pl5_-id1235453.9876543')
67
+ expect(normalized.direction).toBe('outbound')
68
+ expect(normalized.status).toBe('answered')
69
+ expect(normalized.durationSeconds).toBe(7)
70
+ expect(normalized.startedAt?.toISOString()).toBe('2026-05-26T12:07:23.000Z')
71
+ expect(normalized.recording?.url).toContain('.wav')
72
+ expect(normalized.participants).toEqual([
73
+ { role: 'caller', phoneNumber: '48579771838', displayName: 'itrixcom_Kontakt' },
74
+ { role: 'callee', phoneNumber: '48726861925' },
75
+ ])
76
+ expect(normalized.providerFacts).toMatchObject({
77
+ tillioStatus: 'ANSWERED',
78
+ tillioType: 'OUT',
79
+ tillioWaitTimeSeconds: 10,
80
+ operatorId: 'ringostat-1',
81
+ plugin: 'Ringostat',
82
+ })
83
+ expect(normalized.rawPayload).toBe(answeredCall)
84
+ })
85
+
86
+ it('never invents answeredAt or endedAt', () => {
87
+ const normalized = normalizeTillioCall(answeredCall, context)
88
+ expect(normalized.answeredAt).toBeNull()
89
+ expect(normalized.endedAt).toBeNull()
90
+ })
91
+
92
+ it('maps an inbound missed call without a recording', () => {
93
+ const normalized = normalizeTillioCall(missedCall, context)
94
+ expect(normalized.direction).toBe('inbound')
95
+ expect(normalized.status).toBe('missed')
96
+ expect(normalized.durationSeconds).toBe(0)
97
+ expect(normalized.recording).toBeNull()
98
+ expect(normalized.participants[0].displayName).toBeNull()
99
+ })
100
+
101
+ it('does not preserve decoded markup in a caller display name', () => {
102
+ const normalized = normalizeTillioCall({
103
+ ...answeredCall,
104
+ extraFields: {
105
+ ...answeredCall.extraFields,
106
+ caller: '&quot;Safe caller&quot; &lt;script&gt;alert(1)&lt;/script&gt;',
107
+ },
108
+ }, context)
109
+
110
+ expect(normalized.participants[0].displayName).toBe('Safe caller')
111
+ expect(normalized.participants[0].displayName).not.toContain('<')
112
+ })
113
+
114
+ it('tolerates a missing waitTime, which Tillio does not always send', () => {
115
+ const { waitTime: _waitTime, ...withoutWaitTime } = missedCall
116
+ const normalized = normalizeTillioCall(withoutWaitTime, context)
117
+ expect(normalized.providerFacts).not.toHaveProperty('tillioWaitTimeSeconds')
118
+ expect(normalized.status).toBe('missed')
119
+ })
120
+
121
+ it('ignores a recording url when has_recording is not set', () => {
122
+ const normalized = normalizeTillioCall(
123
+ { ...answeredCall, extraFields: { ...answeredCall.extraFields, has_recording: '0' } },
124
+ context,
125
+ )
126
+ expect(normalized.recording).toBeNull()
127
+ })
128
+
129
+ it('keeps the raw payload untouched for provider_facts remapping later', () => {
130
+ const normalized = normalizeTillioCall(missedCall, context)
131
+ expect(normalized.rawPayload).toEqual(missedCall)
132
+ })
133
+
134
+ it('maps an unknown call type to unknown direction', () => {
135
+ const normalized = normalizeTillioCall({ ...missedCall, type: 'INTERNAL' }, context)
136
+ expect(normalized.direction).toBe('unknown')
137
+ })
138
+
139
+ it('throws when the payload has no id', () => {
140
+ const { id: _id, ...withoutId } = missedCall
141
+ expect(() => normalizeTillioCall(withoutId, context)).toThrow()
142
+ })
143
+ })
@@ -0,0 +1,100 @@
1
+ import { POST } from '../api/operators/route'
2
+ import { DELETE } from '../api/operators/[id]/route'
3
+ import { TillioApiError } from '../lib/errors'
4
+ import { TillioRevocationFailedError } from '../lib/operators'
5
+
6
+ jest.mock('@open-mercato/shared/lib/auth/server', () => ({ getAuthFromRequest: jest.fn() }))
7
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({ createRequestContainer: jest.fn() }))
8
+ jest.mock('@open-mercato/shared/lib/crud/route-mutation-guard', () => ({ runRouteMutationGuards: jest.fn() }))
9
+ jest.mock('../lib/operators', () => ({
10
+ ...jest.requireActual('../lib/operators'),
11
+ attachOperator: jest.fn(),
12
+ detachOperator: jest.fn(),
13
+ readTillioIntegrationState: jest.fn(),
14
+ resolveEnvironment: jest.fn(),
15
+ }))
16
+
17
+ const { getAuthFromRequest } = jest.requireMock('@open-mercato/shared/lib/auth/server')
18
+ const { createRequestContainer } = jest.requireMock('@open-mercato/shared/lib/di/container')
19
+ const { runRouteMutationGuards } = jest.requireMock('@open-mercato/shared/lib/crud/route-mutation-guard')
20
+ const {
21
+ attachOperator,
22
+ detachOperator,
23
+ readTillioIntegrationState,
24
+ resolveEnvironment,
25
+ } = jest.requireMock('../lib/operators')
26
+
27
+ const auth = { sub: 'user-1', tenantId: 'tn', orgId: 'org' }
28
+ const originalAppUrl = process.env.APP_URL
29
+
30
+ beforeEach(() => {
31
+ jest.clearAllMocks()
32
+ process.env.APP_URL = 'https://app.example.com'
33
+ getAuthFromRequest.mockResolvedValue(auth)
34
+ createRequestContainer.mockResolvedValue({
35
+ resolve: (name: string) => {
36
+ if (name === 'integrationCredentialsService') return {}
37
+ if (name === 'em') return {}
38
+ throw new Error(`Unexpected dependency: ${name}`)
39
+ },
40
+ })
41
+ runRouteMutationGuards.mockResolvedValue({
42
+ ok: true,
43
+ runAfterSuccess: jest.fn().mockResolvedValue(undefined),
44
+ })
45
+ resolveEnvironment.mockResolvedValue({
46
+ apiUrl: 'https://provider.example.com',
47
+ apiKey: 'api-key',
48
+ tenantSystemId: 'OM-x',
49
+ timeZone: 'Europe/Warsaw',
50
+ })
51
+ readTillioIntegrationState.mockResolvedValue({ enabled: true, healthy: true })
52
+ })
53
+
54
+ afterAll(() => {
55
+ if (originalAppUrl === undefined) delete process.env.APP_URL
56
+ else process.env.APP_URL = originalAppUrl
57
+ })
58
+
59
+ describe('Tillio operator API diagnostics', () => {
60
+ it('does not expose provider response text when attach fails', async () => {
61
+ const providerMessage = 'provider response from https://internal.example.invalid: token secret-token'
62
+ attachOperator.mockRejectedValue(new TillioApiError(providerMessage, 422, 'raw provider body'))
63
+
64
+ const response = await POST(new Request('http://localhost/api/tillio/operators', {
65
+ method: 'POST',
66
+ body: JSON.stringify({ plugin: 'Ringostat', config: { key: 'operator-key' } }),
67
+ }))
68
+ const body = await response.json()
69
+
70
+ expect(response.status).toBe(422)
71
+ expect(body).toEqual({
72
+ ok: false,
73
+ code: 'provider_error',
74
+ section: 'operator',
75
+ message: 'Tillio rejected the request.',
76
+ })
77
+ expect(JSON.stringify(body)).not.toContain(providerMessage)
78
+ })
79
+
80
+ it('does not expose provider response text when detach fails', async () => {
81
+ const providerMessage = 'provider response from https://internal.example.invalid: token secret-token'
82
+ detachOperator.mockRejectedValue(new TillioRevocationFailedError(false, providerMessage))
83
+
84
+ const response = await DELETE(
85
+ new Request('http://localhost/api/tillio/operators/ringostat-1', { method: 'DELETE' }),
86
+ { params: { id: 'ringostat-1' } },
87
+ )
88
+ const body = await response.json()
89
+
90
+ expect(response.status).toBe(502)
91
+ expect(body).toEqual({
92
+ ok: false,
93
+ code: 'revocation_failed',
94
+ section: 'operator',
95
+ message: 'The Tillio token could not be revoked.',
96
+ canForce: true,
97
+ })
98
+ expect(JSON.stringify(body)).not.toContain(providerMessage)
99
+ })
100
+ })
@@ -0,0 +1,104 @@
1
+ import {
2
+ buildTenantDomain,
3
+ computeEnvFingerprint,
4
+ readOperatorsBlob,
5
+ resolveAppHost,
6
+ saveOperatorsBlob,
7
+ TILLIO_OPERATORS_INTEGRATION_ID,
8
+ type TillioCredentialsService,
9
+ } from '../lib/operators-store'
10
+
11
+ const scope = { tenantId: 'tn', organizationId: 'org' }
12
+
13
+ function fakeCredentialsService(initial: Record<string, Record<string, unknown> | null> = {}): {
14
+ service: TillioCredentialsService
15
+ store: Record<string, Record<string, unknown> | null>
16
+ } {
17
+ const store: Record<string, Record<string, unknown> | null> = { ...initial }
18
+ return {
19
+ store,
20
+ service: {
21
+ getRaw: jest.fn(async (id: string) => store[id] ?? null),
22
+ save: jest.fn(async (id: string, credentials: Record<string, unknown>) => {
23
+ store[id] = credentials
24
+ }),
25
+ },
26
+ }
27
+ }
28
+
29
+ describe('computeEnvFingerprint', () => {
30
+ const env = { tenantSystemId: 'OM-x', apiUrl: 'https://a.example.com', apiKey: 'k' }
31
+
32
+ it('is deterministic for the same input', () => {
33
+ expect(computeEnvFingerprint(env)).toBe(computeEnvFingerprint({ ...env }))
34
+ expect(computeEnvFingerprint(env)).toMatch(/^[a-f0-9]{64}$/)
35
+ })
36
+
37
+ it('changes when any field changes', () => {
38
+ expect(computeEnvFingerprint(env)).not.toBe(computeEnvFingerprint({ ...env, apiKey: 'k2' }))
39
+ expect(computeEnvFingerprint(env)).not.toBe(computeEnvFingerprint({ ...env, apiUrl: 'https://b.example.com' }))
40
+ expect(computeEnvFingerprint(env)).not.toBe(computeEnvFingerprint({ ...env, tenantSystemId: 'OM-y' }))
41
+ })
42
+ })
43
+
44
+ describe('resolveAppHost / buildTenantDomain', () => {
45
+ it('strips the scheme and keeps host', () => {
46
+ expect(resolveAppHost('https://app.example.com')).toBe('app.example.com')
47
+ })
48
+
49
+ it('keeps the port when present', () => {
50
+ expect(resolveAppHost('https://app.example.com:8443')).toBe('app.example.com:8443')
51
+ })
52
+
53
+ it('falls back to the authority when there is no scheme', () => {
54
+ expect(resolveAppHost('app.example.com/ignored')).toBe('app.example.com')
55
+ })
56
+
57
+ it('returns empty for a blank url', () => {
58
+ expect(resolveAppHost(' ')).toBe('')
59
+ })
60
+
61
+ it('builds host/{tenantSystemId}-{operatorId}', () => {
62
+ expect(buildTenantDomain('https://app.example.com', 'OM-x', 'ringostat-1')).toBe('app.example.com/OM-x-ringostat-1')
63
+ })
64
+
65
+ it('throws when APP_URL cannot be resolved to a host', () => {
66
+ expect(() => buildTenantDomain(' ', 'OM-x', 'ringostat-1')).toThrow()
67
+ })
68
+ })
69
+
70
+ describe('readOperatorsBlob / saveOperatorsBlob', () => {
71
+ it('returns an empty blob when nothing is stored', async () => {
72
+ const { service } = fakeCredentialsService()
73
+ expect(await readOperatorsBlob(service, scope)).toEqual({ operators: [], defaultOperatorId: null })
74
+ })
75
+
76
+ it('returns an empty blob when the stored value is malformed', async () => {
77
+ const { service } = fakeCredentialsService({ [TILLIO_OPERATORS_INTEGRATION_ID]: { operators: 'nope' } })
78
+ expect(await readOperatorsBlob(service, scope)).toEqual({ operators: [], defaultOperatorId: null })
79
+ })
80
+
81
+ it('parses a valid stored blob', async () => {
82
+ const operator = {
83
+ id: 'ringostat-1',
84
+ plugin: 'Ringostat',
85
+ config: { key: 'secret' },
86
+ token: 'tok',
87
+ tenantDomain: 'app.example.com/OM-x-ringostat-1',
88
+ envFingerprint: 'fp',
89
+ }
90
+ const { service } = fakeCredentialsService({
91
+ [TILLIO_OPERATORS_INTEGRATION_ID]: { operators: [operator], defaultOperatorId: 'ringostat-1' },
92
+ })
93
+ const blob = await readOperatorsBlob(service, scope)
94
+ expect(blob.operators).toHaveLength(1)
95
+ expect(blob.defaultOperatorId).toBe('ringostat-1')
96
+ })
97
+
98
+ it('saves under the tillio_operators key', async () => {
99
+ const { service, store } = fakeCredentialsService()
100
+ await saveOperatorsBlob(service, scope, { operators: [], defaultOperatorId: null })
101
+ expect(service.save).toHaveBeenCalledWith(TILLIO_OPERATORS_INTEGRATION_ID, { operators: [], defaultOperatorId: null }, scope)
102
+ expect(store[TILLIO_OPERATORS_INTEGRATION_ID]).toEqual({ operators: [], defaultOperatorId: null })
103
+ })
104
+ })