@things-factory/headless-twin 10.0.18 → 10.0.19

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 (43) hide show
  1. package/dist-server/routes.js +12 -1
  2. package/dist-server/routes.js.map +1 -1
  3. package/dist-server/service/actuation/command-dispatcher.d.ts +32 -0
  4. package/dist-server/service/actuation/command-dispatcher.js +92 -0
  5. package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
  6. package/dist-server/service/actuation/command-store.d.ts +3 -0
  7. package/dist-server/service/actuation/command-store.js +56 -0
  8. package/dist-server/service/actuation/command-store.js.map +1 -0
  9. package/dist-server/service/actuation/index.d.ts +6 -0
  10. package/dist-server/service/actuation/index.js +11 -0
  11. package/dist-server/service/actuation/index.js.map +1 -0
  12. package/dist-server/service/actuation/twin-command.d.ts +30 -0
  13. package/dist-server/service/actuation/twin-command.js +110 -0
  14. package/dist-server/service/actuation/twin-command.js.map +1 -0
  15. package/dist-server/service/index.d.ts +1 -1
  16. package/dist-server/service/index.js +20 -17
  17. package/dist-server/service/index.js.map +1 -1
  18. package/dist-server/service/reference/hook-contract.d.ts +64 -21
  19. package/dist-server/service/reference/hook-contract.js +134 -22
  20. package/dist-server/service/reference/hook-contract.js.map +1 -1
  21. package/dist-server/service/reference/hook-store.d.ts +3 -0
  22. package/dist-server/service/reference/hook-store.js +28 -0
  23. package/dist-server/service/reference/hook-store.js.map +1 -0
  24. package/dist-server/service/reference/reference-adapter.d.ts +65 -1
  25. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  26. package/dist-server/service/reference/reference-hook.d.ts +42 -6
  27. package/dist-server/service/reference/reference-hook.js +185 -27
  28. package/dist-server/service/reference/reference-hook.js.map +1 -1
  29. package/package.json +7 -7
  30. package/server/routes.ts +12 -1
  31. package/server/service/actuation/command-dispatcher.ts +130 -0
  32. package/server/service/actuation/command-store.ts +62 -0
  33. package/server/service/actuation/index.ts +8 -0
  34. package/server/service/actuation/twin-command.ts +105 -0
  35. package/server/service/index.ts +3 -0
  36. package/server/service/reference/hook-contract.ts +99 -21
  37. package/server/service/reference/hook-store.ts +28 -0
  38. package/server/service/reference/reference-adapter.ts +67 -2
  39. package/server/service/reference/reference-hook.ts +249 -32
  40. package/test/actuation-dispatch.test.ts +196 -0
  41. package/test/hook-sequence.test.ts +333 -0
  42. package/test/reference-hook.test.ts +114 -23
  43. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,333 @@
1
+ /*
2
+ * **밀어 받는 길의 번호** — 빠진 것을 알아채고, 재기동을 넘어 이어받는다.
3
+ *
4
+ * ── 왜 이 시험이 있나 (2026-08-31) ────────────────────────────────────────
5
+ * 훅이 놓치는 것은 예외가 아니라 정상이다. 우리가 내려가 있을 때, 그쪽이 못 보냈을 때, 네트워크가
6
+ * 끊겼을 때. 그 대비가 드문 폴링 하나뿐이었다 — 폴링은 「놓친 것이 있나」를 값으로 알아내는 것이고,
7
+ * 번호는 빠진 것을 곧바로 안다.
8
+ *
9
+ * ── 왜 순수 함수 시험만으로 부족한가 ─────────────────────────────────────
10
+ * 번호 판정 자체는 계약 쪽에서 확인한다(`ops-contract/test/webhook-contract.test.ts`). 여기서 보는
11
+ * 것은 **배선**이다 — 판정이 응답이 되는가, 커서에 적히는가, 다시 세운 뒤에 읽히는가.
12
+ *
13
+ * 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 커서가 정확히 그 모양이었다.
14
+ * `TwinReference.liveCursor` 는 재기동을 넘어 사는 자리인데 **훅 경로가 그것을 읽지도 쓰지도 않았다.**
15
+ * 쓰는 곳은 폴링 하나뿐이었다.
16
+ *
17
+ * 그래서 마지막 시험이 저장본을 그대로 두고 **핸들러를 새로 부른다.** 한 프로세스 안에서만 확인하면
18
+ * 「적히나」는 보이지만 「다시 세우면 읽히나」는 보이지 않는다.
19
+ */
20
+ import { test } from 'node:test'
21
+ import assert from 'node:assert/strict'
22
+
23
+ import { WEBHOOK_STATUS, webhookSenderAction } from '@operato/ops-contract'
24
+
25
+ import { handleHook, type HookStore } from '../server/service/reference/reference-hook.js'
26
+ import { registerAdapterType, getAdapter } from '../server/service/reference/reference-adapter.js'
27
+
28
+ const DOMAIN = 'd-1'
29
+ const SOURCE = 'operato-mes'
30
+ const TWIN = 'twin-a'
31
+ const SECRET = 'shared-secret'
32
+ const SCOPE = 'EVENTS'
33
+ /** 커서 열쇠에는 트윈이 함께 들어간다 — 연결 하나가 트윈 여럿을 만든다. */
34
+ const KEY = `${TWIN}::${SCOPE}`
35
+
36
+ /** 보낸 봉투를 그대로 레코드로 내는 커넥터 — 옮길 것이 없으므로 번호만 꺼낸다. */
37
+ const ADAPTER_TYPE = 'test-numbered-push'
38
+ if (!getAdapter(ADAPTER_TYPE)) {
39
+ registerAdapterType({
40
+ type: ADAPTER_TYPE,
41
+ label: 'numbered push (test)',
42
+ handleInbound: (_cfg: any, _site: any, body: any) => ({
43
+ items: body?.items ?? [{ record: { moverId: 'eq-1', kind: 'press', status: 'idle' }, seq: body?.seq }],
44
+ scope: body?.scope
45
+ })
46
+ } as any)
47
+ }
48
+
49
+ /** 저장본 하나 — 프로세스가 아니라 여기에 산다. 그래서 핸들러를 새로 불러도 남는다. */
50
+ function makeStore(initial?: unknown): HookStore & { row: any } {
51
+ const row = {
52
+ id: 'ref-1',
53
+ source: SOURCE,
54
+ adapterType: ADAPTER_TYPE,
55
+ connectionConfig: { hookSecret: SECRET },
56
+ scopeSpec: { produced: [{ siteId: 'site-a', instanceId: TWIN }] },
57
+ liveCursor: initial
58
+ }
59
+ return {
60
+ row,
61
+ load: async (domainId: string, source: string) => (domainId === DOMAIN && source === SOURCE ? row : null),
62
+ saveCursor: async (refId: string, cursor: unknown) => {
63
+ assert.equal(refId, row.id)
64
+ /* 저장소를 흉내 내는 것이라 참조를 그대로 두지 않고 복사해 넣는다 — 같은 객체면 안 적혀도 통과한다. */
65
+ row.liveCursor = JSON.parse(JSON.stringify(cursor))
66
+ }
67
+ }
68
+ }
69
+
70
+ const applied = (_id: string, records: unknown[]) => ({ applied: records.length, duplicates: 0, rejected: [] })
71
+
72
+ function push(store: HookStore, seq: number | undefined, opts: { scope?: string; ingest?: any } = {}) {
73
+ return handleHook({
74
+ domainId: DOMAIN,
75
+ source: SOURCE,
76
+ instanceId: TWIN,
77
+ headers: { 'x-twin-hook-secret': SECRET },
78
+ body: { seq, scope: 'scope' in opts ? opts.scope : SCOPE },
79
+ nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
80
+ ingest: opts.ingest ?? applied,
81
+ store
82
+ })
83
+ }
84
+
85
+ test('★ 첫 봉투를 받고 그 번호를 커서에 적는다', async () => {
86
+ const store = makeStore()
87
+ const out = await push(store, 918342)
88
+
89
+ assert.equal(out.status, WEBHOOK_STATUS.ok)
90
+ assert.equal(out.body.sequenceChecked, true)
91
+ assert.equal(out.body.lastSeq, 918342)
92
+ assert.equal(store.row.liveCursor?.streams?.[KEY]?.seq, 918342, '커서에 적히지 않았다')
93
+ })
94
+
95
+ test('★ 건너뛴 번호는 409 로 답하고 그 앞부터 달라고 한다', async () => {
96
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
97
+ const out = await push(store, 7)
98
+
99
+ assert.equal(out.status, WEBHOOK_STATUS.sequenceGap)
100
+ assert.equal(out.body.expectedSeq, 5)
101
+ assert.equal(out.body.lastSeq, 4)
102
+ assert.equal(out.body.scope, SCOPE, '어느 줄의 번호인지 함께 말한다')
103
+ })
104
+
105
+ test('★ 구멍이 났을 때 커서를 올리지 않는다 — 올리면 그 구간이 영영 안 온다', async () => {
106
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
107
+ await push(store, 7)
108
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 4)
109
+ })
110
+
111
+ test('★ 이미 본 번호는 받아들이되 커서를 뒤로 끌지 않는다 — 재전송이 정상 동작이다', async () => {
112
+ const store = makeStore({ streams: { [KEY]: { seq: 9 } } })
113
+ const out = await push(store, 9)
114
+
115
+ assert.equal(out.status, WEBHOOK_STATUS.ok)
116
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 9)
117
+ })
118
+
119
+ test('★ 반영되지 않았으면 커서를 올리지 않는다 — 트윈이 안 도는 사이의 번호를 지나치면 그 구간이 빈다', async () => {
120
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
121
+ const out = await push(store, 5, { ingest: () => ({ applied: 0, duplicates: 0, rejected: [] }) })
122
+
123
+ assert.equal(out.status, WEBHOOK_STATUS.notLive)
124
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 4, '한 건도 안 들어갔는데 번호가 올라갔다')
125
+ })
126
+
127
+ test('★ 번호를 매기지 않는 원본은 확인하지 않고, 확인하지 않았다고 말한다', async () => {
128
+ /* chef · ppms 가 그렇다. 「확인했고 이상 없다」와 같아 보이면 어느 연결이 지켜지는지 알 수 없다. */
129
+ const store = makeStore()
130
+ const out = await push(store, undefined, { scope: undefined })
131
+
132
+ assert.equal(out.status, WEBHOOK_STATUS.ok)
133
+ assert.equal(out.body.sequenceChecked, false)
134
+ assert.equal(store.row.liveCursor, undefined, '번호가 없으면 커서를 만들지 않는다')
135
+ })
136
+
137
+ test('★ 번호만 있고 어느 줄인지 없으면 확인하지 않는다 — 짐작한 열쇠는 줄이 늘면 어긋난다', async () => {
138
+ const store = makeStore()
139
+ const out = await push(store, 918342, { scope: undefined })
140
+
141
+ assert.equal(out.status, WEBHOOK_STATUS.ok)
142
+ assert.equal(out.body.sequenceChecked, false)
143
+ })
144
+
145
+ test('★ 줄마다 번호가 따로 간다 — 두 줄이 한 커서에 섞이면 끊임없이 「빠졌다」가 된다', async () => {
146
+ const store = makeStore()
147
+ await push(store, 5, { scope: 'EVENTS' })
148
+ await push(store, 1, { scope: 'MASTER' })
149
+
150
+ assert.equal(store.row.liveCursor.streams[`${TWIN}::EVENTS`].seq, 5)
151
+ assert.equal(store.row.liveCursor.streams[`${TWIN}::MASTER`].seq, 1)
152
+
153
+ /* MASTER 의 1 이 EVENTS 의 5 를 되돌리지 않았으므로 EVENTS 6 이 그대로 이어진다. */
154
+ assert.equal((await push(store, 6, { scope: 'EVENTS' })).status, WEBHOOK_STATUS.ok)
155
+ })
156
+
157
+ test('★ 재기동을 넘어 이어받는다 — 저장본만 남기고 핸들러를 새로 부른다', async () => {
158
+ const first = makeStore()
159
+ await push(first, 5)
160
+ const saved = JSON.parse(JSON.stringify(first.row.liveCursor))
161
+
162
+ /* 프로세스가 새로 선 것으로 본다 — 앞의 저장소 객체는 버리고 저장본만 물려준다. */
163
+ const second = makeStore(saved)
164
+
165
+ assert.equal((await push(second, 7)).status, WEBHOOK_STATUS.sequenceGap, '이어받지 못하면 7 이 그냥 통과한다')
166
+ assert.equal((await push(second, 6)).status, WEBHOOK_STATUS.ok)
167
+ assert.equal(second.row.liveCursor.streams[KEY].seq, 6)
168
+ })
169
+
170
+ test('★ 커서를 적지 못해도 이미 들어간 사실을 무르지 않는다 — 다음 번호에서 구멍으로 드러난다', async () => {
171
+ const store = makeStore()
172
+ const broken: HookStore = { load: store.load, saveCursor: async () => { throw new Error('disk full') } }
173
+
174
+ const out = await push(broken, 5)
175
+ assert.equal(out.status, WEBHOOK_STATUS.ok, '유입은 이미 끝났다 — 커서를 못 적은 것이 그것을 무를 이유가 아니다')
176
+ })
177
+
178
+ /* ── 한 요청에 봉투 여럿 ──────────────────────────────────────────────────── */
179
+
180
+ /** 번호가 붙은 봉투 여럿을 한 요청으로 보낸다 — 밀어 주는 쪽은 한 건씩 보내지 않는다. */
181
+ function pushBatch(store: HookStore, seqs: number[], opts: { scope?: string; ingest?: any } = {}) {
182
+ return handleHook({
183
+ domainId: DOMAIN,
184
+ source: SOURCE,
185
+ instanceId: TWIN,
186
+ headers: { 'x-twin-hook-secret': SECRET },
187
+ body: {
188
+ scope: 'scope' in opts ? opts.scope : SCOPE,
189
+ items: seqs.map(seq => ({ seq, record: { moverId: `eq-${seq}`, kind: 'press', status: 'idle' } }))
190
+ },
191
+ nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
192
+ ingest: opts.ingest ?? applied,
193
+ store
194
+ })
195
+ }
196
+
197
+ test('★ 배치 가운데가 빠지면 그 앞까지만 받는다 — 구멍 뒤를 받으면 그 구멍이 영영 안 메워진다', async () => {
198
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
199
+ const taken: unknown[][] = []
200
+ const out = await pushBatch(store, [5, 6, 9, 10], {
201
+ ingest: (_id: string, records: unknown[]) => {
202
+ taken.push(records)
203
+ return { applied: records.length, duplicates: 0, rejected: [] }
204
+ }
205
+ })
206
+
207
+ assert.equal(taken[0].length, 2, '5·6 만 넣어야 한다')
208
+ assert.equal(out.status, WEBHOOK_STATUS.sequenceGap)
209
+ assert.equal(out.body.expectedSeq, 7)
210
+ assert.equal(out.body.lastSeq, 6, '앞의 것은 받았다는 것을 함께 말한다 — 안 그러면 5·6 을 또 보낸다')
211
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 6)
212
+ })
213
+
214
+ test('★ 배치 전체가 이어지면 끝 번호가 커서가 된다', async () => {
215
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
216
+ const out = await pushBatch(store, [5, 6, 7])
217
+
218
+ assert.equal(out.status, WEBHOOK_STATUS.ok)
219
+ assert.equal(out.body.lastSeq, 7)
220
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 7)
221
+ })
222
+
223
+ test('★ 첫 봉투부터 구멍이면 아무것도 넣지 않고 커서도 그대로다', async () => {
224
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
225
+ let called = false
226
+ const out = await pushBatch(store, [9, 10], {
227
+ ingest: () => {
228
+ called = true
229
+ return { applied: 2, duplicates: 0, rejected: [] }
230
+ }
231
+ })
232
+
233
+ assert.equal(called, false, '받을 것이 없는데 유입을 불렀다')
234
+ assert.equal(out.status, WEBHOOK_STATUS.sequenceGap)
235
+ assert.equal(out.body.expectedSeq, 5)
236
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 4)
237
+ })
238
+
239
+ test('★ 번호가 있는 봉투와 없는 봉투를 섞으면 받지 않는다 — 반쯤 확인한 것을 확인했다고 말하지 않는다', async () => {
240
+ const store = makeStore()
241
+ const out = await handleHook({
242
+ domainId: DOMAIN,
243
+ source: SOURCE,
244
+ instanceId: TWIN,
245
+ headers: { 'x-twin-hook-secret': SECRET },
246
+ body: { scope: SCOPE, items: [{ seq: 1, record: {} }, { record: {} }] },
247
+ nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
248
+ ingest: applied,
249
+ store
250
+ })
251
+
252
+ assert.equal(out.status, WEBHOOK_STATUS.badPayload)
253
+ assert.match(String(out.body.error), /mixed batch/)
254
+ })
255
+
256
+ /* ── 일부만 떨어졌을 때 ───────────────────────────────────────────────────── */
257
+
258
+ const REJECT_ONE = (_id: string, records: unknown[]) => ({
259
+ applied: records.length - 1,
260
+ duplicates: 0,
261
+ rejected: [{ record: records[records.length - 1], errors: ['equipment: 계약에 없는 필드 — foo'] }]
262
+ })
263
+
264
+ test('★ 일부가 떨어지면 422 로 답하고 무엇이 떨어졌는지 함께 준다', async () => {
265
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
266
+ const out = await pushBatch(store, [5, 6], { ingest: REJECT_ONE })
267
+
268
+ assert.equal(out.status, WEBHOOK_STATUS.partial)
269
+ assert.equal((out.body.rejected as any[]).length, 1)
270
+ assert.match(String((out.body.rejected as any[])[0].errors[0]), /계약에 없는 필드/)
271
+ })
272
+
273
+ test('★ 떨어진 것이 있어도 다시 보내라고 하지 않는다 — 나머지는 이미 들어갔다', async () => {
274
+ assert.equal(webhookSenderAction(WEBHOOK_STATUS.partial), 'partial')
275
+ assert.notEqual(webhookSenderAction(WEBHOOK_STATUS.partial), 'resend-from')
276
+ })
277
+
278
+ test('★ 일부가 떨어져도 받은 데까지 커서가 간다 — 안 그러면 그 구간이 매번 다시 온다', async () => {
279
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
280
+ const out = await pushBatch(store, [5, 6], { ingest: REJECT_ONE })
281
+
282
+ assert.equal(out.body.lastSeq, 6)
283
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 6)
284
+ })
285
+
286
+ test('★ 구멍과 떨어진 것이 함께 나면 한 응답에 둘 다 싣는다', async () => {
287
+ /* 두 소식이 갈라져 오면 보내는 쪽은 둘 중 하나만 한다. */
288
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
289
+ const out = await pushBatch(store, [5, 6, 9], { ingest: REJECT_ONE })
290
+
291
+ assert.equal(out.status, WEBHOOK_STATUS.sequenceGap, '연속성이 먼저다 — 그것이 다음에 무엇을 보낼지 정한다')
292
+ assert.equal(out.body.expectedSeq, 7)
293
+ assert.equal(out.body.lastSeq, 6)
294
+ assert.equal((out.body.rejected as any[]).length, 1, '떨어진 것도 함께 알린다')
295
+ })
296
+
297
+ test('전부 통과하면 떨어진 목록이 빈다 — 없는 것과 안 본 것이 같아 보이지 않게', async () => {
298
+ const store = makeStore()
299
+ const out = await pushBatch(store, [1, 2])
300
+ assert.deepEqual(out.body.rejected, [])
301
+ })
302
+
303
+ test('★ 한 연결이 만든 두 트윈이 계수기를 함께 쓰지 않는다', async () => {
304
+ /*
305
+ * 커서는 연결마다 한 행에 산다(`TwinReference.liveCursor`). 그런데 연결 하나가 트윈 여럿을 만든다.
306
+ * 보내는 쪽의 단위 이름만으로 열쇠를 잡으면 두 공장이 한 계수기를 쓰고, B 의 1 이 A 의 5 뒤에서
307
+ * 「이미 본 번호」로 버려진다.
308
+ */
309
+ const store = makeStore()
310
+ const other = 'twin-b'
311
+ store.row.scopeSpec.produced.push({ siteId: 'site-b', instanceId: other })
312
+
313
+ const send = (instanceId: string, seq: number) =>
314
+ handleHook({
315
+ domainId: DOMAIN,
316
+ source: SOURCE,
317
+ instanceId,
318
+ headers: { 'x-twin-hook-secret': SECRET },
319
+ body: { scope: SCOPE, items: [{ seq, record: { moverId: 'eq-1', kind: 'press', status: 'idle' } }] },
320
+ nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
321
+ ingest: applied,
322
+ store
323
+ })
324
+
325
+ await send(TWIN, 5)
326
+ const first = await send(other, 1)
327
+ assert.equal(first.status, WEBHOOK_STATUS.ok, 'B 공장의 첫 봉투가 A 공장의 번호에 걸려 버려졌다')
328
+
329
+ assert.equal(store.row.liveCursor.streams[`${TWIN}::${SCOPE}`].seq, 5)
330
+ assert.equal(store.row.liveCursor.streams[`${other}::${SCOPE}`].seq, 1)
331
+
332
+ assert.equal((await send(TWIN, 6)).status, WEBHOOK_STATUS.ok, 'A 공장이 B 의 번호에 끌려갔다')
333
+ })
@@ -12,7 +12,18 @@
12
12
  */
13
13
  import { test } from 'node:test'
14
14
  import assert from 'node:assert/strict'
15
- import { HOOK_STATUS, secretFromHeaders, secretMatches, producedInstance } from '../server/service/reference/hook-contract.js'
15
+
16
+ import { WEBHOOK_STATUS, webhookSenderAction } from '@operato/ops-contract'
17
+ import { signWebhook, WEBHOOK_HEADER } from '@operato/ops-contract/webhook'
18
+
19
+ import {
20
+ authorizeHook,
21
+ producedInstance,
22
+ secretFromHeaders,
23
+ secretMatches,
24
+ seqOf,
25
+ withSeq
26
+ } from '../server/service/reference/hook-contract.js'
16
27
 
17
28
  test('★ 비밀값을 두 가지 방식으로 받는다 — 밀어 주는 쪽의 관습이 갈린다', () => {
18
29
  assert.equal(secretFromHeaders({ 'x-twin-hook-secret': 'abc' }), 'abc')
@@ -54,33 +65,113 @@ test('★ 그 연결이 만든 트윈만 받는다 — 남의 트윈에 밀어
54
65
  assert.equal(producedInstance({}, 'twin-a'), undefined)
55
66
  })
56
67
 
57
- test('★ 응답 코드가 재시도로 풀리는 것과 그렇지 않은 것을 가른다', () => {
68
+ test('★ 응답 코드 표가 계약에 있다 — 여기에 두 번째 벌을 만들지 않는다', () => {
58
69
  /*
59
- * 재시도해도 같은 것: 비밀값이 다르다 · 모르는 대상 · 옮길 수 없는 본문 · 커넥터가 훅을 모른다
60
- * 사람이 손대야 하는 것: 트윈이 돌지 않는다
61
- * 재시도하면 될 수 있는 것: 우리 쪽 오류
70
+ * 예전에는 이 표가 훅 규약 파일에 있었고 보내는 쪽(operato-mes)에도 있었다. 그래서 같은 409 를 양쪽이
71
+ * 다른 뜻으로 썼다 — 여기서는 「트윈이 안 돈다」, 저쪽에서는 「번호가 비었다」였다.
72
+ *
73
+ * 값을 여기서 다시 단정하지 않는다. 계약이 정본이고 그 시험은 계약 쪽에 있다. 여기서 지키는 것은
74
+ * **이 파일이 계약을 쓰고 있는가**다.
62
75
  */
63
- assert.equal(HOOK_STATUS.ok, 200)
64
- assert.equal(HOOK_STATUS.badPayload, 400)
65
- assert.equal(HOOK_STATUS.badSecret, 401)
66
- assert.equal(HOOK_STATUS.unknownTarget, 404)
67
- assert.equal(HOOK_STATUS.notLive, 409)
68
- assert.equal(HOOK_STATUS.failed, 500)
69
- assert.equal(HOOK_STATUS.unsupported, 501)
70
-
71
- /* 재시도해도 같은 것에 5xx 를 주면 안 된다 — 그쪽이 영원히 다시 보낸다. */
72
- for (const code of [HOOK_STATUS.badPayload, HOOK_STATUS.badSecret, HOOK_STATUS.unknownTarget, HOOK_STATUS.notLive]) {
73
- assert.ok(code < 500, `${code} 는 4xx 여야 한다`)
74
- }
75
- /* 우리 쪽 문제에는 5xx 를 준다 — 그쪽이 다시 보내야 그 사실이 살아남는다. */
76
- assert.ok(HOOK_STATUS.failed >= 500)
76
+ assert.equal(WEBHOOK_STATUS.sequenceGap, 409, '409 는 번호가 비었다는 뜻이다')
77
+ assert.equal(WEBHOOK_STATUS.notLive, 503, '트윈이 안 도는 것은 503 이다')
77
78
  })
78
79
 
79
80
  test('★ 트윈이 돌지 않으면 200 을 주지 않는다 — 그쪽이 그 사실을 버린다', () => {
80
81
  /*
81
- * 200 은 「받아서 반영했다」는 뜻입니다. 트윈이 실시간으로 돌지 않아 한 건도 반영되지 않았는데
82
- * 200 을 주면, 밀어 주는 쪽은 전달된 줄 알고 그 사실을 버립니다. 다시 보내 주지도 않습니다.
82
+ * 200 은 「받아서 반영했다」는 뜻이다. 트윈이 실시간으로 돌지 않아 한 건도 반영되지 않았는데 200 을
83
+ * 주면, 밀어 주는 쪽은 전달된 줄 알고 그 사실을 버린다.
84
+ *
85
+ * 그렇다고 4xx 를 주면 「보낸 요청이 잘못됐다」는 뜻이라 그쪽이 포기할 근거가 된다. 트윈은 누가
86
+ * 띄우면 다시 도므로 들고 기다리는 것이 맞다.
87
+ */
88
+ assert.notEqual(WEBHOOK_STATUS.notLive, WEBHOOK_STATUS.ok)
89
+ assert.equal(webhookSenderAction(WEBHOOK_STATUS.notLive), 'wait')
90
+ })
91
+
92
+ /* ── 인증 — 비밀값과 서명 ─────────────────────────────────────────────────── */
93
+
94
+ const SECRET = 'shared-secret'
95
+ const NOW = Date.parse('2026-08-31T02:00:00.000Z')
96
+ const TS = '2026-08-31T02:00:00.000Z'
97
+ const BODY = '{"batch":[{"seq":1,"scope":"EVENTS","record":{}}]}'
98
+
99
+ test('★ 서명이 오면 서명을 본다', () => {
100
+ const headers = {
101
+ [WEBHOOK_HEADER.timestamp]: TS,
102
+ [WEBHOOK_HEADER.signature]: signWebhook(SECRET, TS, BODY)
103
+ }
104
+ assert.deepEqual(authorizeHook({ headers, rawBody: BODY, secret: SECRET, nowMs: NOW }), {
105
+ ok: true,
106
+ method: 'signature'
107
+ })
108
+ })
109
+
110
+ test('★ 서명이 없으면 비밀값을 본다 — 이미 붙어 있는 커넥터가 그 방식이다', () => {
111
+ const got = authorizeHook({ headers: { 'x-twin-hook-secret': SECRET }, rawBody: BODY, secret: SECRET, nowMs: NOW })
112
+ assert.deepEqual(got, { ok: true, method: 'secret' })
113
+ })
114
+
115
+ test('★ 서명이 왔는데 받은 바이트가 없으면 거절한다 — 확인할 수 없는 것을 통과시키지 않는다', () => {
116
+ /*
117
+ * 파싱한 객체를 다시 문자열로 만들어 확인하고 싶어지는데, 키 순서와 공백이 달라져 어차피 맞지 않는다.
118
+ * 맞지 않는 것을 통과시키면 서명이 있으나 마나 한 것이 된다.
83
119
  */
84
- assert.notEqual(HOOK_STATUS.notLive, HOOK_STATUS.ok)
85
- assert.equal(HOOK_STATUS.notLive, 409)
120
+ const headers = {
121
+ [WEBHOOK_HEADER.timestamp]: TS,
122
+ [WEBHOOK_HEADER.signature]: signWebhook(SECRET, TS, BODY)
123
+ }
124
+ const got = authorizeHook({ headers, rawBody: undefined, secret: SECRET, nowMs: NOW })
125
+ assert.equal(got.ok, false)
126
+ assert.match((got as any).reason, /raw body/)
127
+ })
128
+
129
+ test('★ 서명이 왔으면 비밀값 헤더로 넘어가지 않는다 — 맞는 비밀값을 함께 실어도 서명이 틀리면 거절이다', () => {
130
+ const headers = {
131
+ 'x-twin-hook-secret': SECRET,
132
+ [WEBHOOK_HEADER.timestamp]: TS,
133
+ [WEBHOOK_HEADER.signature]: 'deadbeef'
134
+ }
135
+ const got = authorizeHook({ headers, rawBody: BODY, secret: SECRET, nowMs: NOW })
136
+ assert.equal(got.ok, false)
137
+ })
138
+
139
+ test('거절 사유가 무엇을 고칠지 말한다', () => {
140
+ const stale = new Date(NOW - 10 * 60_000).toISOString()
141
+ const headers = {
142
+ [WEBHOOK_HEADER.timestamp]: stale,
143
+ [WEBHOOK_HEADER.signature]: signWebhook(SECRET, stale, BODY)
144
+ }
145
+ const got = authorizeHook({ headers, rawBody: BODY, secret: SECRET, nowMs: NOW })
146
+ assert.deepEqual(got, { ok: false, reason: 'signature expired' })
147
+ })
148
+
149
+ /* ── 커서에 번호를 적는 자리 ──────────────────────────────────────────────── */
150
+
151
+ test('★ 번호는 폴링이 쓰는 커서와 같은 자리에 산다 — 진행 위치를 두 벌로 두지 않는다', () => {
152
+ const before = { streams: { EVENTS: { since: '2026-08-30T00:00:00.000Z', seen: ['a'] } }, firstAttachedAt: 'x' }
153
+ const after = withSeq(before, 'EVENTS', 918342)
154
+
155
+ assert.equal(seqOf(after, 'EVENTS'), 918342)
156
+ assert.equal((after as any).streams.EVENTS.since, before.streams.EVENTS.since, 'since 를 지우지 않는다')
157
+ assert.deepEqual((after as any).streams.EVENTS.seen, ['a'], 'seen 을 지우지 않는다')
158
+ assert.equal((after as any).firstAttachedAt, 'x', '커서의 다른 항목을 지우지 않는다')
159
+ })
160
+
161
+ test('★ 다른 흐름의 번호를 건드리지 않는다 — 한 단위를 올리면서 다른 단위를 되돌리면 그 구간이 다시 온다', () => {
162
+ const before = { streams: { EVENTS: { seq: 10 }, MASTER: { seq: 4 } } }
163
+ const after = withSeq(before, 'EVENTS', 11)
164
+ assert.equal(seqOf(after, 'EVENTS'), 11)
165
+ assert.equal(seqOf(after, 'MASTER'), 4)
166
+ })
167
+
168
+ test('번호가 없는 흐름은 「모른다」다 — 0 으로 메우지 않는다', () => {
169
+ assert.equal(seqOf(undefined, 'EVENTS'), undefined)
170
+ assert.equal(seqOf({}, 'EVENTS'), undefined)
171
+ assert.equal(seqOf({ streams: {} }, 'EVENTS'), undefined)
172
+ assert.equal(seqOf({ streams: { EVENTS: { since: 'x' } } }, 'EVENTS'), undefined)
173
+ })
174
+
175
+ test('커서가 없던 연결에도 번호를 적을 수 있다', () => {
176
+ assert.equal(seqOf(withSeq(undefined, 'EVENTS', 1), 'EVENTS'), 1)
86
177
  })