/* * ★ 두 테넌트가 **같은 인스턴스 id** 로 각자 돈다 — 실 DB로 경계를 넘어 본다. * * ── 계기 (2026-08-06 테넌트 격리 전수 확인) ────────────────────────────────── * 런타임 레지스트리가 `instanceId` 하나로 키를 잡고 있었다. 그런데 DB 유일성은 `[domain, instanceId]` * 이고 트윈의 정체성도 그쪽이다. **충돌은 이론이 아니다** — 레퍼런스 경로가 `instanceId` 로 레퍼런스 * 소스 이름(`sap-ewm-1710`)을 쓰고, 벤더 시스템 이름은 테넌트가 공유하는 어휘다. * * 그때 무엇이 틀렸나: `start`/`startFromRegistry` 가 **남의 런타임을 돌려주고**, `ingestLive` 는 도메인을 * 확인하지 않아 남의 커널에 이벤트를 적용할 수 있었고, 목록의 `running` 이 남의 런타임을 봤다. * * 이 결함은 **유닛으로는 안 잡힌다** — 두 테넌트가 실제로 등록되고 각자 기동해야 드러난다. 그래서 * 실 엔티티 sqlite 스키마 + 빌드 산출물로 경계를 넘는다(`structure-revision-db.test.ts` 와 같은 하네스). * **소스를 고친 뒤에는 `npm run build:server` 를 먼저 돌려야 반영된다.** */ import { test, before, after, afterEach } from 'node:test' import assert from 'node:assert/strict' import { createRequire } from 'node:module' import type { DataSource } from 'typeorm' const req = createRequire(import.meta.url) const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell') const { entities: authEntities } = req('@things-factory/auth-base') const { entities: twinEntities } = req('../dist-server/service/index.js') const { TwinEngine } = req('../dist-server/engine/index.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let alpha: string let beta: string /** 두 테넌트가 같은 벤더 시스템을 연결했을 때 실제로 생기는 id. */ const SHARED_ID = 'sap-ewm-1710' const model = (): any => ({ spaceId: 'shared-space', utcOffsetMinutes: 0, locations: [ { id: 'dock-recv', type: 'dock', capacity: 100 }, { id: 'bin-1', type: 'storage', capacity: 20 } ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock-recv' }] }) before(async () => { ds = new TypeOrmDataSource({ type: 'sqlite', database: ':memory:', synchronize: true, dropSchema: true, namingStrategy: new NamingStrategy(), entities: [...shellEntities, ...authEntities, ...twinEntities], logging: false }) await ds.initialize() addDataSource('default', ds) const a = await ds.getRepository(Domain).save({ name: 'tenant-alpha', subdomain: 'tenant-alpha' } as any) const b = await ds.getRepository(Domain).save({ name: 'tenant-beta', subdomain: 'tenant-beta' } as any) alpha = a.id beta = b.id /* 두 테넌트가 **같은 id** 로 각자 프로비저닝한다 — DB 유일성이 `[domain, instanceId]` 라 허용된다. */ await TwinEngine.provision(alpha, SHARED_ID, 'wms', model(), 'reset') await TwinEngine.provision(beta, SHARED_ID, 'wms', model(), 'reset') }) /** * **스펙마다 반드시 멈춘다.** 남기면 두 가지가 따라온다: 틱 타이머 때문에 러너가 끝나지 않고, 틱이 * 뒤늦게 오류를 내는 DB 쓰기가 teardown 이후에 도착해 `SQLITE_MISUSE` 로 스위트를 오염시킨다. */ afterEach(async () => { await TwinEngine.stop(alpha, SHARED_ID).catch(() => {}) await TwinEngine.stop(beta, SHARED_ID).catch(() => {}) }) after(async () => { /* 정지 직후에도 진행 중인 쓰기가 있을 수 있다 — 커넥션을 닫기 전에 가라앉힌다. */ await new Promise(resolve => setTimeout(resolve, 120)) await ds?.destroy() }) test('두 테넌트가 같은 id 로 프로비저닝된다 — 서로를 막지 않는다', async () => { /* 예전에는 한쪽이 **떠 있으면** 다른 쪽 재프로비전이 "instance is running" 으로 막혔다(존재 노출). */ const listA = await TwinEngine.list(alpha) const listB = await TwinEngine.list(beta) assert.equal(listA.filter((x: any) => x.instanceId === SHARED_ID).length, 1) assert.equal(listB.filter((x: any) => x.instanceId === SHARED_ID).length, 1) }) test('한쪽만 기동하면 다른 쪽은 **정지한 것으로** 보인다 — 이 회귀가 핵심', async () => { await TwinEngine.startFromRegistry(alpha, SHARED_ID) assert.equal(TwinEngine.owns(alpha, SHARED_ID), true, 'alpha 는 자기 트윈을 갖는다') assert.equal(TwinEngine.owns(beta, SHARED_ID), false, 'beta 는 기동하지 않았다') /* 목록의 running 이 남의 런타임을 보면 안 된다 — 예전에는 beta 도 running: true 였다. */ const rowA = (await TwinEngine.list(alpha)).find((x: any) => x.instanceId === SHARED_ID) const rowB = (await TwinEngine.list(beta)).find((x: any) => x.instanceId === SHARED_ID) assert.equal(rowA.running, true) assert.equal(rowB.running, false) }) test('둘 다 기동하면 **서로 다른 런타임**이다 — 남의 것을 돌려받지 않는다', async () => { const rtA = await TwinEngine.startFromRegistry(alpha, SHARED_ID) const rtB = await TwinEngine.startFromRegistry(beta, SHARED_ID) /* 예전에는 두 번째 호출이 첫 번째의 런타임을 그대로 돌려줬다(조기 반환이 id 로만 찾았다). */ assert.notEqual(rtA, rtB, '같은 객체를 돌려주면 두 테넌트가 한 커널을 공유한다') assert.notEqual(TwinEngine.kernel(alpha, SHARED_ID), TwinEngine.kernel(beta, SHARED_ID)) assert.equal(rtA.domainId, alpha) assert.equal(rtB.domainId, beta) }) test('접근자가 테넌트별로 답한다 — 커널·스냅샷·런타임', async () => { await TwinEngine.startFromRegistry(alpha, SHARED_ID) await TwinEngine.startFromRegistry(beta, SHARED_ID) for (const [who, other] of [ [alpha, beta], [beta, alpha] ]) { assert.ok(TwinEngine.kernel(who, SHARED_ID), '자기 커널은 있다') assert.notEqual(TwinEngine.kernel(who, SHARED_ID), TwinEngine.kernel(other, SHARED_ID)) assert.notEqual(TwinEngine.runtime(who, SHARED_ID), TwinEngine.runtime(other, SHARED_ID)) } }) test('한쪽을 정지해도 다른 쪽은 계속 동작한다 — 남의 런타임을 지우지 않는다', async () => { await TwinEngine.startFromRegistry(alpha, SHARED_ID) await TwinEngine.startFromRegistry(beta, SHARED_ID) await TwinEngine.stop(alpha, SHARED_ID) assert.equal(TwinEngine.owns(alpha, SHARED_ID), false, '정지한 쪽은 사라진다') assert.equal(TwinEngine.owns(beta, SHARED_ID), true, '남의 트윈은 그대로다') assert.ok(TwinEngine.kernel(beta, SHARED_ID), 'beta 커널이 살아 있다') }) test('없는 테넌트로 물으면 아무것도 주지 않는다 — 추측한 id 로 새지 않는다', async () => { await TwinEngine.startFromRegistry(alpha, SHARED_ID) const stranger = '99999999-9999-9999-9999-999999999999' assert.equal(TwinEngine.owns(stranger, SHARED_ID), false) assert.equal(TwinEngine.kernel(stranger, SHARED_ID), undefined) assert.equal(TwinEngine.runtime(stranger, SHARED_ID), undefined) assert.equal(TwinEngine.metrics(stranger, SHARED_ID), null) })