@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,250 @@
1
+ import { createTillioClient } from '../lib/client'
2
+ import { TillioApiError } from '../lib/errors'
3
+ import { TILLIO_INTEGRATION_ID } from '../integration'
4
+ import { TILLIO_OPERATORS_INTEGRATION_ID, type TillioCredentialsService } from '../lib/operators-store'
5
+ import {
6
+ attachOperator,
7
+ classifyTillioError,
8
+ detachOperator,
9
+ TillioEnvironmentNotReadyError,
10
+ TillioOperatorLimitError,
11
+ } from '../lib/operators'
12
+
13
+ jest.mock('../lib/client', () => ({
14
+ createTillioClient: jest.fn(),
15
+ }))
16
+
17
+ const createTillioClientMock = createTillioClient as jest.MockedFunction<typeof createTillioClient>
18
+
19
+ const scope = { tenantId: 'tn', organizationId: 'org' }
20
+ const appUrl = 'https://app.example.com'
21
+ // These cases drive one attach at a time, so the section needs no serialization; the advisory
22
+ // lock itself needs a database and is exercised where the route wires it.
23
+ const passThroughLock = <T>(run: () => Promise<T>): Promise<T> => run()
24
+ const readyEnv = { apiUrl: 'https://a.example.com', apiKey: 'k', tenantSystemId: 'OM-x' }
25
+
26
+ function fakeStore(initial: Record<string, Record<string, unknown> | null> = {}): {
27
+ service: TillioCredentialsService
28
+ store: Record<string, Record<string, unknown> | null>
29
+ } {
30
+ const store: Record<string, Record<string, unknown> | null> = { ...initial }
31
+ return {
32
+ store,
33
+ service: {
34
+ getRaw: jest.fn(async (id: string) => store[id] ?? null),
35
+ save: jest.fn(async (id: string, credentials: Record<string, unknown>) => {
36
+ store[id] = credentials
37
+ }),
38
+ },
39
+ }
40
+ }
41
+
42
+ function mockClient(overrides: Partial<{
43
+ validateConfig: jest.Mock
44
+ addConfig: jest.Mock
45
+ deleteConfig: jest.Mock
46
+ }> = {}) {
47
+ const client = {
48
+ validateConfig: overrides.validateConfig ?? jest.fn().mockResolvedValue(undefined),
49
+ addConfig: overrides.addConfig ?? jest.fn().mockResolvedValue({ token: 'tok-1' }),
50
+ deleteConfig: overrides.deleteConfig ?? jest.fn().mockResolvedValue(undefined),
51
+ }
52
+ createTillioClientMock.mockReturnValue(client as unknown as ReturnType<typeof createTillioClient>)
53
+ return client
54
+ }
55
+
56
+ beforeEach(() => {
57
+ createTillioClientMock.mockReset()
58
+ })
59
+
60
+ describe('attachOperator', () => {
61
+ it('validates, provisions, and persists the operator with an env fingerprint', async () => {
62
+ const client = mockClient()
63
+ const { service, store } = fakeStore({ [TILLIO_INTEGRATION_ID]: { ...readyEnv } })
64
+
65
+ const record = await attachOperator({ credentialsService: service, scope, appUrl, withLock: passThroughLock }, {
66
+ plugin: 'Ringostat',
67
+ config: { key: 'ringo-key' },
68
+ })
69
+
70
+ expect(client.validateConfig).toHaveBeenCalledWith('Ringostat', { key: 'ringo-key' }, 'app.example.com/OM-x-ringostat-1')
71
+ expect(client.addConfig).toHaveBeenCalledWith('Ringostat', { key: 'ringo-key' }, 'app.example.com/OM-x-ringostat-1')
72
+ expect(record.id).toBe('ringostat-1')
73
+ expect(record.token).toBe('tok-1')
74
+ expect(record.tenantDomain).toBe('app.example.com/OM-x-ringostat-1')
75
+ expect(record.envFingerprint).toEqual(expect.any(String))
76
+
77
+ const saved = store[TILLIO_OPERATORS_INTEGRATION_ID] as { operators: unknown[]; defaultOperatorId: string }
78
+ expect(saved.operators).toHaveLength(1)
79
+ expect(saved.defaultOperatorId).toBe('ringostat-1')
80
+ })
81
+
82
+ it('rejects a second operator (one at a time)', async () => {
83
+ const client = mockClient()
84
+ const { service } = fakeStore({
85
+ [TILLIO_INTEGRATION_ID]: { ...readyEnv },
86
+ [TILLIO_OPERATORS_INTEGRATION_ID]: {
87
+ operators: [{ id: 'ringostat-1', plugin: 'Ringostat', config: { key: 'x' }, token: 't', tenantDomain: 'd', envFingerprint: 'fp' }],
88
+ defaultOperatorId: 'ringostat-1',
89
+ },
90
+ })
91
+
92
+ await expect(attachOperator({ credentialsService: service, scope, appUrl, withLock: passThroughLock }, { plugin: 'Ringostat', config: { key: 'k' } }))
93
+ .rejects.toBeInstanceOf(TillioOperatorLimitError)
94
+ expect(client.validateConfig).not.toHaveBeenCalled()
95
+ })
96
+
97
+ it('rejects when the environment is not ready', async () => {
98
+ const client = mockClient()
99
+ const { service } = fakeStore({ [TILLIO_INTEGRATION_ID]: { apiUrl: 'https://a.example.com', apiKey: 'k' } })
100
+
101
+ await expect(attachOperator({ credentialsService: service, scope, appUrl, withLock: passThroughLock }, { plugin: 'Ringostat', config: { key: 'k' } }))
102
+ .rejects.toBeInstanceOf(TillioEnvironmentNotReadyError)
103
+ expect(client.addConfig).not.toHaveBeenCalled()
104
+ })
105
+
106
+ it('compensates with deleteConfig when persistence fails', async () => {
107
+ const client = mockClient()
108
+ const store: Record<string, Record<string, unknown> | null> = { [TILLIO_INTEGRATION_ID]: { ...readyEnv } }
109
+ const service: TillioCredentialsService = {
110
+ getRaw: jest.fn(async (id: string) => store[id] ?? null),
111
+ save: jest.fn(async (id: string) => {
112
+ if (id === TILLIO_OPERATORS_INTEGRATION_ID) throw new Error('disk full')
113
+ }),
114
+ }
115
+
116
+ await expect(attachOperator({ credentialsService: service, scope, appUrl, withLock: passThroughLock }, { plugin: 'Ringostat', config: { key: 'k' } }))
117
+ .rejects.toThrow('disk full')
118
+ expect(client.deleteConfig).toHaveBeenCalledWith('Ringostat', 'tok-1', 'app.example.com/OM-x-ringostat-1')
119
+ })
120
+
121
+ it('gives up rather than overwriting an operator a concurrent attach stored first', async () => {
122
+ const client = mockClient()
123
+ const store: Record<string, Record<string, unknown> | null> = { [TILLIO_INTEGRATION_ID]: { ...readyEnv } }
124
+ let reads = 0
125
+ const service: TillioCredentialsService = {
126
+ getRaw: jest.fn(async (id: string) => {
127
+ // The second read is the one taken right before the write: by then a concurrent
128
+ // attach has already stored its operator.
129
+ if (id === TILLIO_OPERATORS_INTEGRATION_ID && ++reads === 2) {
130
+ return {
131
+ operators: [{ id: 'ringostat-1', plugin: 'Ringostat', config: { key: 'other' }, token: 'other-token', tenantDomain: 'd', envFingerprint: 'fp' }],
132
+ defaultOperatorId: 'ringostat-1',
133
+ }
134
+ }
135
+ return store[id] ?? null
136
+ }),
137
+ save: jest.fn(async (id: string, credentials: Record<string, unknown>) => {
138
+ store[id] = credentials
139
+ }),
140
+ }
141
+
142
+ await expect(attachOperator({ credentialsService: service, scope, appUrl, withLock: passThroughLock }, { plugin: 'Ringostat', config: { key: 'k' } }))
143
+ .rejects.toBeInstanceOf(TillioOperatorLimitError)
144
+ expect(client.deleteConfig).toHaveBeenCalledWith('Ringostat', 'tok-1', 'app.example.com/OM-x-ringostat-1')
145
+ expect(store[TILLIO_OPERATORS_INTEGRATION_ID]).toBeUndefined()
146
+ })
147
+ })
148
+
149
+ describe('detachOperator', () => {
150
+ it('revokes the token and removes the operator', async () => {
151
+ const client = mockClient()
152
+ const { service, store } = fakeStore({
153
+ [TILLIO_INTEGRATION_ID]: { ...readyEnv },
154
+ [TILLIO_OPERATORS_INTEGRATION_ID]: {
155
+ operators: [{ id: 'ringostat-1', plugin: 'Ringostat', config: { key: 'x' }, token: 'tok-1', tenantDomain: 'app.example.com/OM-x-ringostat-1', envFingerprint: 'fp' }],
156
+ defaultOperatorId: 'ringostat-1',
157
+ },
158
+ })
159
+
160
+ const result = await detachOperator({ credentialsService: service, scope }, 'ringostat-1')
161
+
162
+ expect(result).toEqual({ ok: true, detached: true, revoked: true })
163
+ expect(client.deleteConfig).toHaveBeenCalledWith('Ringostat', 'tok-1', 'app.example.com/OM-x-ringostat-1')
164
+ const saved = store[TILLIO_OPERATORS_INTEGRATION_ID] as { operators: unknown[]; defaultOperatorId: string | null }
165
+ expect(saved.operators).toHaveLength(0)
166
+ expect(saved.defaultOperatorId).toBeNull()
167
+ })
168
+
169
+ it('is a no-op for an unknown operator id', async () => {
170
+ const client = mockClient()
171
+ const { service } = fakeStore({
172
+ [TILLIO_INTEGRATION_ID]: { ...readyEnv },
173
+ [TILLIO_OPERATORS_INTEGRATION_ID]: { operators: [], defaultOperatorId: null },
174
+ })
175
+
176
+ const result = await detachOperator({ credentialsService: service, scope }, 'missing')
177
+
178
+ expect(result).toEqual({ ok: true, detached: false, revoked: false })
179
+ expect(client.deleteConfig).not.toHaveBeenCalled()
180
+ })
181
+
182
+ it('keeps the operator when the provider refuses the revocation', async () => {
183
+ const providerMessage = 'provider response from https://internal.example.invalid: token secret-token'
184
+ mockClient({ deleteConfig: jest.fn().mockRejectedValue(new TillioApiError(providerMessage, 422, 'invalid')) })
185
+ const { service, store } = fakeStore({
186
+ [TILLIO_INTEGRATION_ID]: { ...readyEnv },
187
+ [TILLIO_OPERATORS_INTEGRATION_ID]: {
188
+ operators: [{ id: 'ringostat-1', plugin: 'Ringostat', config: { key: 'x' }, token: 'tok-1', tenantDomain: 'd', envFingerprint: 'fp' }],
189
+ defaultOperatorId: 'ringostat-1',
190
+ },
191
+ })
192
+
193
+ await expect(detachOperator({ credentialsService: service, scope }, 'ringostat-1'))
194
+ .rejects.toMatchObject({
195
+ name: 'TillioRevocationFailedError',
196
+ environmentMissing: false,
197
+ message: 'Tillio did not confirm the operator token was revoked.',
198
+ })
199
+
200
+ const saved = store[TILLIO_OPERATORS_INTEGRATION_ID] as { operators: unknown[] }
201
+ expect(saved.operators).toHaveLength(1)
202
+ })
203
+
204
+ it('keeps the operator when the environment is gone, so the token is not orphaned silently', async () => {
205
+ const client = mockClient()
206
+ const { service, store } = fakeStore({
207
+ [TILLIO_OPERATORS_INTEGRATION_ID]: {
208
+ operators: [{ id: 'ringostat-1', plugin: 'Ringostat', config: { key: 'x' }, token: 'tok-1', tenantDomain: 'd', envFingerprint: 'fp' }],
209
+ defaultOperatorId: 'ringostat-1',
210
+ },
211
+ })
212
+
213
+ await expect(detachOperator({ credentialsService: service, scope }, 'ringostat-1'))
214
+ .rejects.toMatchObject({ name: 'TillioRevocationFailedError', environmentMissing: true })
215
+
216
+ expect(client.deleteConfig).not.toHaveBeenCalled()
217
+ const saved = store[TILLIO_OPERATORS_INTEGRATION_ID] as { operators: unknown[] }
218
+ expect(saved.operators).toHaveLength(1)
219
+ })
220
+
221
+ it('removes the operator on a forced detach and reports the token was not revoked', async () => {
222
+ mockClient({ deleteConfig: jest.fn().mockRejectedValue(new TillioApiError('nope', 422, 'invalid')) })
223
+ const { service, store } = fakeStore({
224
+ [TILLIO_INTEGRATION_ID]: { ...readyEnv },
225
+ [TILLIO_OPERATORS_INTEGRATION_ID]: {
226
+ operators: [{ id: 'ringostat-1', plugin: 'Ringostat', config: { key: 'x' }, token: 'tok-1', tenantDomain: 'd', envFingerprint: 'fp' }],
227
+ defaultOperatorId: 'ringostat-1',
228
+ },
229
+ })
230
+
231
+ const result = await detachOperator({ credentialsService: service, scope }, 'ringostat-1', { force: true })
232
+
233
+ expect(result).toEqual({ ok: true, detached: true, revoked: false })
234
+ const saved = store[TILLIO_OPERATORS_INTEGRATION_ID] as { operators: unknown[] }
235
+ expect(saved.operators).toHaveLength(0)
236
+ })
237
+ })
238
+
239
+ describe('classifyTillioError', () => {
240
+ it('attributes transport/auth failures to the environment', () => {
241
+ expect(classifyTillioError(new TillioApiError('x', 0, 'network'))).toBe('environment')
242
+ expect(classifyTillioError(new TillioApiError('x', 401, 'unauthorized'))).toBe('environment')
243
+ expect(classifyTillioError(new TillioApiError('x', 403, 'forbidden'))).toBe('environment')
244
+ })
245
+
246
+ it('attributes config failures to the operator', () => {
247
+ expect(classifyTillioError(new TillioApiError('x', 422, 'invalid'))).toBe('operator')
248
+ expect(classifyTillioError(new Error('boom'))).toBe('operator')
249
+ })
250
+ })
@@ -0,0 +1,337 @@
1
+ import { TILLIO_INTEGRATION_ID } from '../integration'
2
+ import { TillioOperatorLimitError } from '../lib/operators'
3
+ import { TILLIO_OPERATORS_INTEGRATION_ID, type TillioCredentialsService } from '../lib/operators-store'
4
+ import { applyTillioEnvPreset, readTillioEnvPreset, TILLIO_ENV_VARS } from '../lib/preset'
5
+
6
+ jest.mock('../lib/operators', () => ({
7
+ ...jest.requireActual('../lib/operators'),
8
+ attachOperator: jest.fn(),
9
+ detachOperator: jest.fn(),
10
+ }))
11
+
12
+ const { attachOperator, detachOperator } = jest.requireMock('../lib/operators')
13
+
14
+ const otherEnvironment = { apiUrl: 'https://other.example.com', apiKey: 'other-key' }
15
+
16
+ const attachedOperator = {
17
+ id: 'ringostat-1',
18
+ plugin: 'Ringostat' as const,
19
+ config: { key: 'old' },
20
+ token: 'tok-old',
21
+ tenantDomain: 'tenant.example.com',
22
+ envFingerprint: 'fp-old',
23
+ }
24
+
25
+ const scope = { tenantId: 'tn', organizationId: 'org' }
26
+
27
+ const completeEnv = {
28
+ [TILLIO_ENV_VARS.apiUrl]: 'https://api.example.com',
29
+ [TILLIO_ENV_VARS.apiKey]: 'key-1',
30
+ } as NodeJS.ProcessEnv
31
+
32
+ function fakeStore(initial: Record<string, Record<string, unknown> | null> = {}): {
33
+ service: TillioCredentialsService
34
+ store: Record<string, Record<string, unknown> | null>
35
+ } {
36
+ const store: Record<string, Record<string, unknown> | null> = { ...initial }
37
+ return {
38
+ store,
39
+ service: {
40
+ getRaw: jest.fn(async (id: string) => store[id] ?? null),
41
+ save: jest.fn(async (id: string, credentials: Record<string, unknown>) => {
42
+ store[id] = credentials
43
+ }),
44
+ },
45
+ }
46
+ }
47
+
48
+ function fakeServices(healthStatus = 'healthy') {
49
+ return {
50
+ integrationStateService: { upsert: jest.fn().mockResolvedValue(undefined) },
51
+ integrationHealthService: { runHealthCheck: jest.fn().mockResolvedValue({ status: healthStatus }) },
52
+ }
53
+ }
54
+
55
+ beforeEach(() => {
56
+ attachOperator.mockReset()
57
+ attachOperator.mockResolvedValue({ id: 'ringostat-1' })
58
+ detachOperator.mockReset()
59
+ detachOperator.mockResolvedValue({ ok: true, detached: true, revoked: true })
60
+ })
61
+
62
+ describe('readTillioEnvPreset', () => {
63
+ it('reports absent when no Tillio variable is set', () => {
64
+ expect(readTillioEnvPreset({} as NodeJS.ProcessEnv)).toEqual({ status: 'absent' })
65
+ })
66
+
67
+ it('names the missing variable when the preset is half configured', () => {
68
+ const preset = readTillioEnvPreset({ [TILLIO_ENV_VARS.apiUrl]: 'https://api.example.com' } as NodeJS.ProcessEnv)
69
+
70
+ expect(preset).toEqual({ status: 'incomplete', missing: [TILLIO_ENV_VARS.apiKey] })
71
+ })
72
+
73
+ it('treats a blank value as unset', () => {
74
+ const preset = readTillioEnvPreset({ ...completeEnv, [TILLIO_ENV_VARS.apiKey]: ' ' } as NodeJS.ProcessEnv)
75
+
76
+ expect(preset).toEqual({ status: 'incomplete', missing: [TILLIO_ENV_VARS.apiKey] })
77
+ })
78
+
79
+ it('carries a configured time zone into the credentials and rejects an invalid one', () => {
80
+ const preset = readTillioEnvPreset({ ...completeEnv, [TILLIO_ENV_VARS.timeZone]: 'America/New_York' } as NodeJS.ProcessEnv)
81
+
82
+ expect(preset).toMatchObject({ credentials: { timeZone: 'America/New_York' } })
83
+ expect(() => readTillioEnvPreset({ ...completeEnv, [TILLIO_ENV_VARS.timeZone]: 'Europe/Atlantis' } as NodeJS.ProcessEnv))
84
+ .toThrow()
85
+ })
86
+
87
+ it('reads a complete preset with the operator key and the force flag', () => {
88
+ const preset = readTillioEnvPreset({
89
+ ...completeEnv,
90
+ [TILLIO_ENV_VARS.ringostatKey]: 'ringostat-key',
91
+ [TILLIO_ENV_VARS.force]: 'true',
92
+ } as NodeJS.ProcessEnv)
93
+
94
+ expect(preset).toEqual({
95
+ status: 'ready',
96
+ credentials: { apiUrl: 'https://api.example.com', apiKey: 'key-1' },
97
+ ringostatKey: 'ringostat-key',
98
+ force: true,
99
+ replaceOperator: false,
100
+ })
101
+ })
102
+ })
103
+
104
+ describe('applyTillioEnvPreset', () => {
105
+ it('saves credentials, enables the integration and runs the health check', async () => {
106
+ const { service, store } = fakeStore()
107
+ const services = fakeServices()
108
+
109
+ const result = await applyTillioEnvPreset({
110
+ credentialsService: service,
111
+ ...services,
112
+ scope,
113
+ env: completeEnv,
114
+ })
115
+
116
+ expect(result).toEqual({
117
+ status: 'applied',
118
+ credentialsAction: 'saved',
119
+ health: 'healthy',
120
+ operator: 'not-requested',
121
+ })
122
+ expect(store[TILLIO_INTEGRATION_ID]).toEqual({ apiUrl: 'https://api.example.com', apiKey: 'key-1' })
123
+ expect(services.integrationStateService.upsert).toHaveBeenCalledWith(
124
+ TILLIO_INTEGRATION_ID,
125
+ { isEnabled: true },
126
+ scope,
127
+ )
128
+ expect(services.integrationHealthService.runHealthCheck).toHaveBeenCalledWith(TILLIO_INTEGRATION_ID, scope)
129
+ expect(attachOperator).not.toHaveBeenCalled()
130
+ })
131
+
132
+ it('attaches the operator through the same call the UI route makes', async () => {
133
+ const { service } = fakeStore()
134
+ const services = fakeServices()
135
+
136
+ const result = await applyTillioEnvPreset({
137
+ credentialsService: service,
138
+ ...services,
139
+ scope,
140
+ appUrl: 'https://app.example.com',
141
+ env: { ...completeEnv, [TILLIO_ENV_VARS.ringostatKey]: 'ringostat-key' },
142
+ })
143
+
144
+ expect(result).toMatchObject({ operator: 'attached' })
145
+ expect(attachOperator).toHaveBeenCalledWith(
146
+ {
147
+ credentialsService: service,
148
+ scope,
149
+ appUrl: 'https://app.example.com',
150
+ withLock: expect.any(Function),
151
+ },
152
+ { plugin: 'Ringostat', config: { key: 'ringostat-key' } },
153
+ )
154
+ })
155
+
156
+ it('reports the operator as kept when the slot is already taken', async () => {
157
+ attachOperator.mockRejectedValue(new TillioOperatorLimitError())
158
+ const { service } = fakeStore()
159
+
160
+ const result = await applyTillioEnvPreset({
161
+ credentialsService: service,
162
+ ...fakeServices(),
163
+ scope,
164
+ appUrl: 'https://app.example.com',
165
+ env: { ...completeEnv, [TILLIO_ENV_VARS.ringostatKey]: 'ringostat-key' },
166
+ })
167
+
168
+ expect(result).toMatchObject({ operator: 'kept' })
169
+ })
170
+
171
+ it('does not attach against an environment the health check rejected', async () => {
172
+ const { service } = fakeStore()
173
+
174
+ const result = await applyTillioEnvPreset({
175
+ credentialsService: service,
176
+ ...fakeServices('unhealthy'),
177
+ scope,
178
+ appUrl: 'https://app.example.com',
179
+ env: { ...completeEnv, [TILLIO_ENV_VARS.ringostatKey]: 'ringostat-key' },
180
+ })
181
+
182
+ expect(result).toMatchObject({ health: 'unhealthy', operator: 'failed' })
183
+ expect(attachOperator).not.toHaveBeenCalled()
184
+ })
185
+
186
+ it('writes nothing on an incomplete preset and reports the missing variable', async () => {
187
+ const { service } = fakeStore()
188
+ const services = fakeServices()
189
+
190
+ const result = await applyTillioEnvPreset({
191
+ credentialsService: service,
192
+ ...services,
193
+ scope,
194
+ env: { [TILLIO_ENV_VARS.apiKey]: 'key-1' } as NodeJS.ProcessEnv,
195
+ })
196
+
197
+ expect(result).toEqual({
198
+ status: 'skipped',
199
+ reason: `Incomplete Tillio env preset; missing ${TILLIO_ENV_VARS.apiUrl}.`,
200
+ })
201
+ expect(service.save).not.toHaveBeenCalled()
202
+ expect(services.integrationStateService.upsert).not.toHaveBeenCalled()
203
+ expect(services.integrationHealthService.runHealthCheck).not.toHaveBeenCalled()
204
+ })
205
+
206
+ it('keeps credentials that already exist unless forced', async () => {
207
+ const existing = { apiUrl: 'https://rotated.example.com', apiKey: 'rotated' }
208
+ const { service, store } = fakeStore({ [TILLIO_INTEGRATION_ID]: existing })
209
+
210
+ const kept = await applyTillioEnvPreset({
211
+ credentialsService: service,
212
+ ...fakeServices(),
213
+ scope,
214
+ env: completeEnv,
215
+ })
216
+
217
+ expect(kept).toMatchObject({ credentialsAction: 'kept' })
218
+ expect(store[TILLIO_INTEGRATION_ID]).toEqual(existing)
219
+
220
+ const forced = await applyTillioEnvPreset({
221
+ credentialsService: service,
222
+ ...fakeServices(),
223
+ scope,
224
+ env: { ...completeEnv, [TILLIO_ENV_VARS.force]: 'true' },
225
+ })
226
+
227
+ expect(forced).toMatchObject({ credentialsAction: 'saved' })
228
+ expect(store[TILLIO_INTEGRATION_ID]).toEqual({ apiUrl: 'https://api.example.com', apiKey: 'key-1' })
229
+ })
230
+
231
+ it('refuses to switch environments while an operator is attached', async () => {
232
+ const { service, store } = fakeStore({
233
+ [TILLIO_INTEGRATION_ID]: otherEnvironment,
234
+ [TILLIO_OPERATORS_INTEGRATION_ID]: { operators: [attachedOperator], defaultOperatorId: attachedOperator.id },
235
+ })
236
+
237
+ const result = await applyTillioEnvPreset({
238
+ credentialsService: service,
239
+ ...fakeServices(),
240
+ scope,
241
+ appUrl: 'https://app.example.com',
242
+ env: {
243
+ ...completeEnv,
244
+ [TILLIO_ENV_VARS.ringostatKey]: 'ringostat-key',
245
+ [TILLIO_ENV_VARS.force]: 'true',
246
+ },
247
+ })
248
+
249
+ expect(result.status).toBe('blocked')
250
+ expect(detachOperator).not.toHaveBeenCalled()
251
+ expect(store[TILLIO_INTEGRATION_ID]).toEqual(otherEnvironment)
252
+ })
253
+
254
+ it('revokes the attached operator before the switch once it is confirmed', async () => {
255
+ const { service, store } = fakeStore({
256
+ [TILLIO_INTEGRATION_ID]: otherEnvironment,
257
+ [TILLIO_OPERATORS_INTEGRATION_ID]: { operators: [attachedOperator], defaultOperatorId: attachedOperator.id },
258
+ })
259
+ const confirmOperatorReplacement = jest.fn().mockResolvedValue(true)
260
+
261
+ const result = await applyTillioEnvPreset({
262
+ credentialsService: service,
263
+ ...fakeServices(),
264
+ scope,
265
+ appUrl: 'https://app.example.com',
266
+ confirmOperatorReplacement,
267
+ env: {
268
+ ...completeEnv,
269
+ [TILLIO_ENV_VARS.ringostatKey]: 'ringostat-key',
270
+ [TILLIO_ENV_VARS.force]: 'true',
271
+ },
272
+ })
273
+
274
+ expect(confirmOperatorReplacement).toHaveBeenCalledWith(attachedOperator)
275
+ // Revoked while the old credentials are still stored, otherwise the token cannot be revoked at all.
276
+ expect(detachOperator.mock.invocationCallOrder[0])
277
+ .toBeLessThan((service.save as jest.Mock).mock.invocationCallOrder.at(-1) as number)
278
+ expect(store[TILLIO_INTEGRATION_ID]).toEqual({ apiUrl: 'https://api.example.com', apiKey: 'key-1' })
279
+ expect(result).toMatchObject({ credentialsAction: 'saved', operator: 'attached' })
280
+ })
281
+
282
+ it('takes the env variable as the answer for unattended runs', async () => {
283
+ const { service } = fakeStore({
284
+ [TILLIO_INTEGRATION_ID]: otherEnvironment,
285
+ [TILLIO_OPERATORS_INTEGRATION_ID]: { operators: [attachedOperator], defaultOperatorId: attachedOperator.id },
286
+ })
287
+
288
+ const result = await applyTillioEnvPreset({
289
+ credentialsService: service,
290
+ ...fakeServices(),
291
+ scope,
292
+ appUrl: 'https://app.example.com',
293
+ env: {
294
+ ...completeEnv,
295
+ [TILLIO_ENV_VARS.ringostatKey]: 'ringostat-key',
296
+ [TILLIO_ENV_VARS.force]: 'true',
297
+ [TILLIO_ENV_VARS.replaceOperator]: 'true',
298
+ },
299
+ })
300
+
301
+ expect(detachOperator).toHaveBeenCalled()
302
+ expect(result).toMatchObject({ operator: 'attached' })
303
+ })
304
+
305
+ it('refuses the switch when no key is available to reattach the operator', async () => {
306
+ const { service } = fakeStore({
307
+ [TILLIO_INTEGRATION_ID]: otherEnvironment,
308
+ [TILLIO_OPERATORS_INTEGRATION_ID]: { operators: [attachedOperator], defaultOperatorId: attachedOperator.id },
309
+ })
310
+
311
+ const result = await applyTillioEnvPreset({
312
+ credentialsService: service,
313
+ ...fakeServices(),
314
+ scope,
315
+ env: { ...completeEnv, [TILLIO_ENV_VARS.force]: 'true', [TILLIO_ENV_VARS.replaceOperator]: 'true' },
316
+ })
317
+
318
+ expect(result.status).toBe('blocked')
319
+ expect(detachOperator).not.toHaveBeenCalled()
320
+ })
321
+
322
+ it('leaves the operator store untouched when no preset is present', async () => {
323
+ const { service } = fakeStore()
324
+ const services = fakeServices()
325
+
326
+ const result = await applyTillioEnvPreset({
327
+ credentialsService: service,
328
+ ...services,
329
+ scope,
330
+ env: {} as NodeJS.ProcessEnv,
331
+ })
332
+
333
+ expect(result.status).toBe('skipped')
334
+ expect(service.save).not.toHaveBeenCalled()
335
+ expect(service.getRaw).not.toHaveBeenCalledWith(TILLIO_OPERATORS_INTEGRATION_ID, scope)
336
+ })
337
+ })
@@ -0,0 +1,43 @@
1
+ import { pullBodySchema } from '../api/pull/route'
2
+
3
+ const validBody = { from: '2026-05-01', to: '2026-05-31' }
4
+
5
+ describe('pullBodySchema', () => {
6
+ it('accepts a well-formed day range', () => {
7
+ expect(pullBodySchema.safeParse(validBody).success).toBe(true)
8
+ })
9
+
10
+ it('rejects days that look right but are not on the calendar', () => {
11
+ for (const day of ['2026-02-30', '2026-04-31', '2026-06-31', '2026-09-31', '2026-11-31']) {
12
+ expect(pullBodySchema.safeParse({ ...validBody, from: day }).success).toBe(false)
13
+ expect(pullBodySchema.safeParse({ ...validBody, to: day }).success).toBe(false)
14
+ }
15
+ })
16
+
17
+ it('rejects out-of-range month and day numbers', () => {
18
+ for (const day of ['2026-99-99', '2026-13-01', '2026-00-10', '2026-01-00', '2026-01-32']) {
19
+ expect(pullBodySchema.safeParse({ ...validBody, from: day }).success).toBe(false)
20
+ }
21
+ })
22
+
23
+ it('follows the leap year rule for February 29th', () => {
24
+ expect(pullBodySchema.safeParse({ from: '2024-02-29', to: '2024-02-29' }).success).toBe(true)
25
+ expect(pullBodySchema.safeParse({ from: '2025-02-29', to: '2025-02-29' }).success).toBe(false)
26
+ expect(pullBodySchema.safeParse({ from: '2000-02-29', to: '2000-02-29' }).success).toBe(true)
27
+ expect(pullBodySchema.safeParse({ from: '1900-02-29', to: '1900-02-29' }).success).toBe(false)
28
+ })
29
+
30
+ it('rejects shapes that are not a plain day', () => {
31
+ for (const day of ['2026-5-1', '26-05-01', '2026-05-01T00:00:00Z', '2026-05-01 ', '']) {
32
+ expect(pullBodySchema.safeParse({ ...validBody, from: day }).success).toBe(false)
33
+ }
34
+ })
35
+
36
+ it('rejects a range that ends before it starts', () => {
37
+ expect(pullBodySchema.safeParse({ from: '2026-05-31', to: '2026-05-01' }).success).toBe(false)
38
+ })
39
+
40
+ it('accepts a single-day range', () => {
41
+ expect(pullBodySchema.safeParse({ from: '2026-05-01', to: '2026-05-01' }).success).toBe(true)
42
+ })
43
+ })