@things-factory/shell 10.0.0 → 10.0.3
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.
- package/dist-server/migrations/1785369600000-IndexDomainOwner.d.ts +5 -0
- package/dist-server/migrations/1785369600000-IndexDomainOwner.js +49 -0
- package/dist-server/migrations/1785369600000-IndexDomainOwner.js.map +1 -0
- package/dist-server/service/domain/domain.js +12 -1
- package/dist-server/service/domain/domain.js.map +1 -1
- package/dist-server/service/env-var/env-var-mutation.js +6 -1
- package/dist-server/service/env-var/env-var-mutation.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/index.d.ts +1 -0
- package/dist-server/utils/index.js +1 -0
- package/dist-server/utils/index.js.map +1 -1
- package/package.json +2 -2
- package/tests/domain-owner-index.test.ts +121 -0
- package/tests/env-var-tenant-isolation.test.ts +118 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const tslib_1 = require("tslib");
|
|
4
4
|
tslib_1.__exportStar(require("./get-domain.js"), exports);
|
|
5
|
+
tslib_1.__exportStar(require("./apply-domain-filter.js"), exports);
|
|
5
6
|
tslib_1.__exportStar(require("./condition-builder.js"), exports);
|
|
6
7
|
tslib_1.__exportStar(require("./list-query-builder.js"), exports);
|
|
7
8
|
tslib_1.__exportStar(require("./list-params-converter.js"), exports);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/utils/index.ts"],"names":[],"mappings":";;;AAAA,0DAA+B;AAC/B,iEAAsC;AACtC,kEAAuC;AACvC,qEAA0C;AAC1C,gEAAqC;AACrC,kFAAuD;AACvD,mEAAwC;AACxC,oEAAyC;AACzC,mEAAwC","sourcesContent":["export * from './get-domain.js'\nexport * from './condition-builder.js'\nexport * from './list-query-builder.js'\nexport * from './list-params-converter.js'\nexport * from './publish-progress.js'\nexport * from './get-query-builder-from-list-params.js'\nexport * from './list-param-adjuster.js'\nexport * from './get-times-for-period.js'\nexport * from './headless-pool/index.js'\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/utils/index.ts"],"names":[],"mappings":";;;AAAA,0DAA+B;AAC/B,mEAAwC;AACxC,iEAAsC;AACtC,kEAAuC;AACvC,qEAA0C;AAC1C,gEAAqC;AACrC,kFAAuD;AACvD,mEAAwC;AACxC,oEAAyC;AACzC,mEAAwC","sourcesContent":["export * from './get-domain.js'\nexport * from './apply-domain-filter.js'\nexport * from './condition-builder.js'\nexport * from './list-query-builder.js'\nexport * from './list-params-converter.js'\nexport * from './publish-progress.js'\nexport * from './get-query-builder-from-list-params.js'\nexport * from './list-param-adjuster.js'\nexport * from './get-times-for-period.js'\nexport * from './headless-pool/index.js'\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/shell",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.3",
|
|
4
4
|
"description": "Core module for framework",
|
|
5
5
|
"bin": {
|
|
6
6
|
"things-factory": "bin/things-factory",
|
|
@@ -126,5 +126,5 @@
|
|
|
126
126
|
"pg": "^8.7.3",
|
|
127
127
|
"sqlite3": "^5.0.8"
|
|
128
128
|
},
|
|
129
|
-
"gitHead": "
|
|
129
|
+
"gitHead": "c7d253b722b6917e8911f6ef6e78e5fc2b295e82"
|
|
130
130
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `domains.owner` 인덱스 — 실제 엔티티 메타데이터와 실제 마이그레이션으로 검증.
|
|
3
|
+
*
|
|
4
|
+
* 운영 설정은 `synchronize: false` 이므로 두 경로를 따로 확인한다.
|
|
5
|
+
* - 엔티티 `@Index` 선언 → 신규 설치 경로
|
|
6
|
+
* - `IndexDomainOwner` 마이그레이션 → 기존 설치 경로
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DataSource, IsNull, QueryRunner, TableIndex } from 'typeorm'
|
|
10
|
+
|
|
11
|
+
import { Domain, NamingStrategy, entities as shellEntities } from '@things-factory/shell'
|
|
12
|
+
|
|
13
|
+
import { IndexDomainOwner1785369600000 } from '../server/migrations/1785369600000-IndexDomainOwner.js'
|
|
14
|
+
|
|
15
|
+
const INDEX_NAME = 'ix_domain_1'
|
|
16
|
+
|
|
17
|
+
let dataSource: DataSource
|
|
18
|
+
let queryRunner: QueryRunner
|
|
19
|
+
|
|
20
|
+
async function findIndex(): Promise<TableIndex | undefined> {
|
|
21
|
+
const table = await queryRunner.getTable('domains')
|
|
22
|
+
return table?.indices.find(index => index.name === INDEX_NAME)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function saveDomain(name: string, owner?: string): Promise<Domain> {
|
|
26
|
+
return await dataSource.getRepository(Domain).save({ name, subdomain: name, owner } as any)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
dataSource = new DataSource({
|
|
31
|
+
type: 'sqlite',
|
|
32
|
+
database: ':memory:',
|
|
33
|
+
synchronize: true,
|
|
34
|
+
dropSchema: true,
|
|
35
|
+
namingStrategy: new NamingStrategy(),
|
|
36
|
+
entities: [...shellEntities],
|
|
37
|
+
logging: false
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
await dataSource.initialize()
|
|
41
|
+
queryRunner = dataSource.createQueryRunner()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
await queryRunner?.release()
|
|
46
|
+
await dataSource?.destroy()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('엔티티 선언 (신규 설치)', () => {
|
|
50
|
+
it('owner 컬럼에 인덱스가 생성된다', async () => {
|
|
51
|
+
const index = await findIndex()
|
|
52
|
+
|
|
53
|
+
expect(index).toBeDefined()
|
|
54
|
+
expect(index.columnNames).toEqual(['owner'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('non-unique 라서 한 사용자가 여러 도메인을 소유할 수 있다', async () => {
|
|
58
|
+
expect((await findIndex()).isUnique).toBe(false)
|
|
59
|
+
|
|
60
|
+
const owner = 'same-owner-id'
|
|
61
|
+
await saveDomain('multi-a', owner)
|
|
62
|
+
await saveDomain('multi-b', owner)
|
|
63
|
+
|
|
64
|
+
expect(await dataSource.getRepository(Domain).count({ where: { owner } })).toBe(2)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('owner 가 비어 있는 도메인도 여러 개 존재할 수 있다', async () => {
|
|
68
|
+
await saveDomain('orphan-a')
|
|
69
|
+
await saveDomain('orphan-b')
|
|
70
|
+
|
|
71
|
+
const orphans = await dataSource.getRepository(Domain).find({ where: { owner: IsNull() } })
|
|
72
|
+
|
|
73
|
+
expect(orphans.map(domain => domain.subdomain).sort()).toEqual(['orphan-a', 'orphan-b'])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('마이그레이션 (기존 설치)', () => {
|
|
78
|
+
const migration = new IndexDomainOwner1785369600000()
|
|
79
|
+
|
|
80
|
+
afterEach(async () => {
|
|
81
|
+
/* 다음 검사를 위해 인덱스를 되돌려 둔다. */
|
|
82
|
+
if (!(await findIndex())) {
|
|
83
|
+
await migration.up(queryRunner)
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('인덱스가 없는 DB 에 인덱스를 만든다', async () => {
|
|
88
|
+
await queryRunner.dropIndex('domains', INDEX_NAME)
|
|
89
|
+
expect(await findIndex()).toBeUndefined()
|
|
90
|
+
|
|
91
|
+
await migration.up(queryRunner)
|
|
92
|
+
|
|
93
|
+
const index = await findIndex()
|
|
94
|
+
expect(index.columnNames).toEqual(['owner'])
|
|
95
|
+
expect(index.isUnique).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('이미 있으면 아무것도 하지 않는다 (idempotent)', async () => {
|
|
99
|
+
expect(await findIndex()).toBeDefined()
|
|
100
|
+
|
|
101
|
+
await expect(migration.up(queryRunner)).resolves.not.toThrow()
|
|
102
|
+
|
|
103
|
+
expect(await findIndex()).toBeDefined()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('되돌리면 인덱스만 사라지고 데이터는 남는다', async () => {
|
|
107
|
+
const saved = await saveDomain('rollback-target', 'owner-id')
|
|
108
|
+
|
|
109
|
+
await migration.down(queryRunner)
|
|
110
|
+
|
|
111
|
+
expect(await findIndex()).toBeUndefined()
|
|
112
|
+
expect((await dataSource.getRepository(Domain).findOneBy({ id: saved.id })).owner).toBe('owner-id')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('인덱스가 없는 상태에서 되돌려도 실패하지 않는다', async () => {
|
|
116
|
+
await migration.down(queryRunner)
|
|
117
|
+
expect(await findIndex()).toBeUndefined()
|
|
118
|
+
|
|
119
|
+
await expect(migration.down(queryRunner)).resolves.not.toThrow()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 테넌트 격리 회귀 테스트 — 일괄 갱신이 다른 도메인의 행을 건드리지 못한다.
|
|
3
|
+
*
|
|
4
|
+
* `updateMultipleX` 계열은 스캐폴딩이 찍어낸 형태라 여러 패키지에 같은 코드가 복제돼 있었고,
|
|
5
|
+
* 클라이언트가 준 id 로 도메인 조건 없이 행을 집어 저장했다. 즉 자기 테넌트에서 해당 권한만
|
|
6
|
+
* 있으면 UUID 를 아는 다른 테넌트의 행을 조용히 수정할 수 있었다.
|
|
7
|
+
*
|
|
8
|
+
* 여기서는 `EnvVar`(테넌트별 환경변수 — 값이 가장 민감한 축) 로 그 형태를 잠근다.
|
|
9
|
+
* 리졸버를 직접 호출한다 — `@Directive` 는 GraphQL 스키마 계층이라 직접 호출에 개입하지 않고,
|
|
10
|
+
* 트랜잭션은 `context.state.tx` 로 주입한다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DataSource } from 'typeorm'
|
|
14
|
+
|
|
15
|
+
import { Domain, EnvVar, NamingStrategy, entities as shellEntities } from '@things-factory/shell'
|
|
16
|
+
|
|
17
|
+
/* 리졸버는 패키지 공개 표면이 아니라 dist-server 에서 직접 가져온다 (빌드 후 실행할 것). */
|
|
18
|
+
import { EnvVarMutation } from '../dist-server/service/env-var/env-var-mutation.js'
|
|
19
|
+
|
|
20
|
+
let dataSource: DataSource
|
|
21
|
+
|
|
22
|
+
const mutation = new EnvVarMutation()
|
|
23
|
+
|
|
24
|
+
function contextFor(domain: Domain) {
|
|
25
|
+
return { state: { domain, user: null, tx: dataSource.manager } } as any
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function createDomain(name: string): Promise<Domain> {
|
|
29
|
+
return await dataSource.getRepository(Domain).save({ name, subdomain: name } as any)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function createEnvVar(domain: Domain, name: string, value: string): Promise<EnvVar> {
|
|
33
|
+
return await dataSource.getRepository(EnvVar).save({ domain, name, value } as any)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
dataSource = new DataSource({
|
|
38
|
+
type: 'sqlite',
|
|
39
|
+
database: ':memory:',
|
|
40
|
+
synchronize: true,
|
|
41
|
+
dropSchema: true,
|
|
42
|
+
namingStrategy: new NamingStrategy(),
|
|
43
|
+
entities: [...shellEntities],
|
|
44
|
+
logging: false
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
await dataSource.initialize()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await dataSource?.destroy()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
await dataSource.query(`DELETE FROM "${dataSource.getMetadata(EnvVar).tableName}"`)
|
|
56
|
+
await dataSource.query(`DELETE FROM "${dataSource.getMetadata(Domain).tableName}"`)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('updateMultipleEnvVars 테넌트 격리', () => {
|
|
60
|
+
it('다른 도메인의 행은 갱신하지 못하고 실패한다', async () => {
|
|
61
|
+
const victim = await createDomain('victim')
|
|
62
|
+
const attacker = await createDomain('attacker')
|
|
63
|
+
const secret = await createEnvVar(victim, 'API_ENDPOINT', 'https://victim.example.com')
|
|
64
|
+
|
|
65
|
+
await expect(
|
|
66
|
+
mutation.updateMultipleEnvVars(
|
|
67
|
+
[{ cuFlag: 'M', id: secret.id, value: 'https://attacker.example.com' } as any],
|
|
68
|
+
contextFor(attacker)
|
|
69
|
+
)
|
|
70
|
+
).rejects.toThrow(secret.id)
|
|
71
|
+
|
|
72
|
+
/* 피해 도메인의 값이 그대로여야 한다 */
|
|
73
|
+
const kept = await dataSource.getRepository(EnvVar).findOneBy({ id: secret.id })
|
|
74
|
+
expect(kept.value).toBe('https://victim.example.com')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('없는 id 는 조용히 새 행을 만들지 않고 실패한다', async () => {
|
|
78
|
+
const domain = await createDomain('acme')
|
|
79
|
+
|
|
80
|
+
await expect(
|
|
81
|
+
mutation.updateMultipleEnvVars(
|
|
82
|
+
[{ cuFlag: 'M', id: '00000000-0000-0000-0000-000000000000', value: 'x' } as any],
|
|
83
|
+
contextFor(domain)
|
|
84
|
+
)
|
|
85
|
+
).rejects.toThrow()
|
|
86
|
+
|
|
87
|
+
expect(await dataSource.getRepository(EnvVar).count()).toBe(0)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('자기 도메인의 행은 정상적으로 갱신한다', async () => {
|
|
91
|
+
const domain = await createDomain('acme')
|
|
92
|
+
const envVar = await createEnvVar(domain, 'API_ENDPOINT', 'https://old.example.com')
|
|
93
|
+
|
|
94
|
+
const results = await mutation.updateMultipleEnvVars(
|
|
95
|
+
[{ cuFlag: 'M', id: envVar.id, value: 'https://new.example.com' } as any],
|
|
96
|
+
contextFor(domain)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
expect(results).toHaveLength(1)
|
|
100
|
+
const updated = await dataSource.getRepository(EnvVar).findOneBy({ id: envVar.id })
|
|
101
|
+
expect(updated.value).toBe('https://new.example.com')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('생성은 현재 도메인으로 귀속된다', async () => {
|
|
105
|
+
const domain = await createDomain('acme')
|
|
106
|
+
|
|
107
|
+
await mutation.updateMultipleEnvVars(
|
|
108
|
+
[{ cuFlag: '+', name: 'NEW_VAR', value: 'v' } as any],
|
|
109
|
+
contextFor(domain)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const created = await dataSource.getRepository(EnvVar).findOne({
|
|
113
|
+
where: { name: 'NEW_VAR' },
|
|
114
|
+
relations: ['domain']
|
|
115
|
+
})
|
|
116
|
+
expect(created.domain.id).toBe(domain.id)
|
|
117
|
+
})
|
|
118
|
+
})
|