/* * ★ 연결된 시스템이 밀어 주는 사실을 받는 자리 — 규약을 시험이 지킨다. * * ── 왜 만들었나 (2026-08-26 · 사용자 지시) ────────────────────────────────── * 지금 모든 커넥터가 주기적으로 물어봅니다. 그러면 연결된 시스템이 값을 갱신하는 주기보다 자주 묻게 * 되고, 그쪽 서버에 그만큼 부담을 줍니다. 태양광 발전소에 10초마다 물으면서 10분마다 바뀌는 값을 * 받아 왔고, 그날 그쪽 서버가 부하로 멈췄습니다. * * ── 무엇을 지키나 ─────────────────────────────────────────────────────────── * 응답 코드가 규약입니다. 밀어 주는 쪽은 그 코드로 재시도를 정하므로, 우리가 아무렇게나 주면 고칠 수 * 없는 것을 영원히 다시 보내거나, 잠깐의 문제로 사라진 사실을 아무도 모릅니다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { WEBHOOK_STATUS, webhookSenderAction } from '@operato/ops-contract' import { signWebhook, WEBHOOK_HEADER } from '@operato/ops-contract/webhook' import { authorizeHook, producedInstance, secretFromHeaders, secretMatches, seqOf, withSeq } from '../server/service/reference/hook-contract.js' test('★ 비밀값을 두 가지 방식으로 받는다 — 밀어 주는 쪽의 관습이 갈린다', () => { assert.equal(secretFromHeaders({ 'x-twin-hook-secret': 'abc' }), 'abc') assert.equal(secretFromHeaders({ authorization: 'Bearer abc' }), 'abc') assert.equal(secretFromHeaders({ authorization: 'bearer abc ' }), 'abc') assert.equal(secretFromHeaders({}), '') assert.equal(secretFromHeaders(undefined), '') }) test('★ 전용 헤더가 먼저다 — 둘 다 오면 헷갈리지 않는다', () => { assert.equal(secretFromHeaders({ 'x-twin-hook-secret': 'first', authorization: 'Bearer second' }), 'first') }) test('★ 빈 비밀값은 어떤 것과도 맞지 않는다 — 선언하지 않은 연결은 훅을 받지 않는다', () => { assert.equal(secretMatches('', ''), false) assert.equal(secretMatches('anything', ''), false) assert.equal(secretMatches('', 'expected'), false) }) test('★ 비밀값 비교는 길이가 같으면 전부를 본다 — 응답 시간으로 알아내지 못하게', () => { assert.equal(secretMatches('secret-value', 'secret-value'), true) assert.equal(secretMatches('secret-valuX', 'secret-value'), false) assert.equal(secretMatches('Xecret-value', 'secret-value'), false) assert.equal(secretMatches('short', 'secret-value'), false) }) test('★ 그 연결이 만든 트윈만 받는다 — 남의 트윈에 밀어 넣지 못한다', () => { const ref = { scopeSpec: { produced: [ { siteId: 'site-a', instanceId: 'twin-a' }, { siteId: 'site-b', instanceId: 'twin-b' } ] } } assert.deepEqual(producedInstance(ref, 'twin-b'), { siteId: 'site-b' }) assert.equal(producedInstance(ref, 'twin-c'), undefined) assert.equal(producedInstance(null, 'twin-a'), undefined) assert.equal(producedInstance({}, 'twin-a'), undefined) }) test('★ 응답 코드 표가 계약에 있다 — 여기에 두 번째 벌을 만들지 않는다', () => { /* * 예전에는 이 표가 훅 규약 파일에 있었고 보내는 쪽(operato-plant)에도 있었다. 그래서 같은 409 를 양쪽이 * 다른 뜻으로 썼다 — 여기서는 「트윈이 안 돈다」, 저쪽에서는 「번호가 비었다」였다. * * 값을 여기서 다시 단정하지 않는다. 계약이 정본이고 그 시험은 계약 쪽에 있다. 여기서 지키는 것은 * **이 파일이 계약을 쓰고 있는가**다. */ assert.equal(WEBHOOK_STATUS.sequenceGap, 409, '409 는 번호가 비었다는 뜻이다') assert.equal(WEBHOOK_STATUS.notLive, 503, '트윈이 안 도는 것은 503 이다') }) test('★ 트윈이 돌지 않으면 200 을 주지 않는다 — 그쪽이 그 사실을 버린다', () => { /* * 200 은 「받아서 반영했다」는 뜻이다. 트윈이 실시간으로 돌지 않아 한 건도 반영되지 않았는데 200 을 * 주면, 밀어 주는 쪽은 전달된 줄 알고 그 사실을 버린다. * * 그렇다고 4xx 를 주면 「보낸 요청이 잘못됐다」는 뜻이라 그쪽이 포기할 근거가 된다. 트윈은 누가 * 띄우면 다시 도므로 들고 기다리는 것이 맞다. */ assert.notEqual(WEBHOOK_STATUS.notLive, WEBHOOK_STATUS.ok) assert.equal(webhookSenderAction(WEBHOOK_STATUS.notLive), 'wait') }) /* ── 인증 — 비밀값과 서명 ─────────────────────────────────────────────────── */ const SECRET = 'shared-secret' const NOW = Date.parse('2026-08-31T02:00:00.000Z') const TS = '2026-08-31T02:00:00.000Z' const BODY = '{"batch":[{"seq":1,"scope":"EVENTS","record":{}}]}' test('★ 서명이 오면 서명을 본다', () => { const headers = { [WEBHOOK_HEADER.timestamp]: TS, [WEBHOOK_HEADER.signature]: signWebhook(SECRET, TS, BODY) } assert.deepEqual(authorizeHook({ headers, rawBody: BODY, secret: SECRET, nowMs: NOW }), { ok: true, method: 'signature' }) }) test('★ 서명이 없으면 비밀값을 본다 — 이미 붙어 있는 커넥터가 그 방식이다', () => { const got = authorizeHook({ headers: { 'x-twin-hook-secret': SECRET }, rawBody: BODY, secret: SECRET, nowMs: NOW }) assert.deepEqual(got, { ok: true, method: 'secret' }) }) test('★ 서명이 왔는데 받은 바이트가 없으면 거절한다 — 확인할 수 없는 것을 통과시키지 않는다', () => { /* * 파싱한 객체를 다시 문자열로 만들어 확인하고 싶어지는데, 키 순서와 공백이 달라져 어차피 맞지 않는다. * 맞지 않는 것을 통과시키면 서명이 있으나 마나 한 것이 된다. */ const headers = { [WEBHOOK_HEADER.timestamp]: TS, [WEBHOOK_HEADER.signature]: signWebhook(SECRET, TS, BODY) } const got = authorizeHook({ headers, rawBody: undefined, secret: SECRET, nowMs: NOW }) assert.equal(got.ok, false) assert.match((got as any).reason, /raw body/) }) test('★ 서명이 왔으면 비밀값 헤더로 넘어가지 않는다 — 맞는 비밀값을 함께 실어도 서명이 틀리면 거절이다', () => { const headers = { 'x-twin-hook-secret': SECRET, [WEBHOOK_HEADER.timestamp]: TS, [WEBHOOK_HEADER.signature]: 'deadbeef' } const got = authorizeHook({ headers, rawBody: BODY, secret: SECRET, nowMs: NOW }) assert.equal(got.ok, false) }) test('거절 사유가 무엇을 고칠지 말한다', () => { const stale = new Date(NOW - 10 * 60_000).toISOString() const headers = { [WEBHOOK_HEADER.timestamp]: stale, [WEBHOOK_HEADER.signature]: signWebhook(SECRET, stale, BODY) } const got = authorizeHook({ headers, rawBody: BODY, secret: SECRET, nowMs: NOW }) assert.deepEqual(got, { ok: false, reason: 'signature expired' }) }) /* ── 커서에 번호를 적는 자리 ──────────────────────────────────────────────── */ test('★ 번호는 폴링이 쓰는 커서와 같은 자리에 산다 — 진행 위치를 두 벌로 두지 않는다', () => { const before = { streams: { EVENTS: { since: '2026-08-30T00:00:00.000Z', seen: ['a'] } }, firstAttachedAt: 'x' } const after = withSeq(before, 'EVENTS', 918342) assert.equal(seqOf(after, 'EVENTS'), 918342) assert.equal((after as any).streams.EVENTS.since, before.streams.EVENTS.since, 'since 를 지우지 않는다') assert.deepEqual((after as any).streams.EVENTS.seen, ['a'], 'seen 을 지우지 않는다') assert.equal((after as any).firstAttachedAt, 'x', '커서의 다른 항목을 지우지 않는다') }) test('★ 다른 흐름의 번호를 건드리지 않는다 — 한 단위를 올리면서 다른 단위를 되돌리면 그 구간이 다시 온다', () => { const before = { streams: { EVENTS: { seq: 10 }, MASTER: { seq: 4 } } } const after = withSeq(before, 'EVENTS', 11) assert.equal(seqOf(after, 'EVENTS'), 11) assert.equal(seqOf(after, 'MASTER'), 4) }) test('번호가 없는 흐름은 「모른다」다 — 0 으로 메우지 않는다', () => { assert.equal(seqOf(undefined, 'EVENTS'), undefined) assert.equal(seqOf({}, 'EVENTS'), undefined) assert.equal(seqOf({ streams: {} }, 'EVENTS'), undefined) assert.equal(seqOf({ streams: { EVENTS: { since: 'x' } } }, 'EVENTS'), undefined) }) test('커서가 없던 연결에도 번호를 적을 수 있다', () => { assert.equal(seqOf(withSeq(undefined, 'EVENTS', 1), 'EVENTS'), 1) })