@things-factory/shell 10.0.0 → 10.0.4
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
- package/tests/postgres-param-type-inference.test.ts +122 -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.4",
|
|
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": "c8f6dcfea3ffe7a9dd2a7195e83e2e4e201fd5ea"
|
|
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
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres 파라미터 타입 추론 함정 회귀 검사.
|
|
3
|
+
*
|
|
4
|
+
* 배경: `domains.owner` 는 사용자 id(uuid 문자열)를 담지만 컬럼 타입이 varchar 다. 반면
|
|
5
|
+
* `domain_owners.user_id` 등 다른 사용자 참조는 진짜 uuid 컬럼이다.
|
|
6
|
+
*
|
|
7
|
+
* 한 쿼리에서 **같은 이름의 파라미터**를 varchar 컬럼과 uuid 컬럼 양쪽에 쓰면, Postgres 드라이버가
|
|
8
|
+
* 그 파라미터를 하나의 플레이스홀더(`$1`)로 재사용하고 Postgres 는 첫 사용처로 타입을 추론한다.
|
|
9
|
+
* 그 결과 `uuid = $1` 비교가 `operator does not exist: uuid = text` 로 실패한다.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ sqlite 드라이버는 같은 이름을 별도 플레이스홀더(`?`, `?`)로 복제하기 때문에 이 결함이
|
|
12
|
+
* 통과한다. 즉 이 저장소의 sqlite 기반 테스트로는 원리적으로 잡히지 않는다. 그래서
|
|
13
|
+
* 서버 없이 Postgres 용 SQL 만 생성해(`buildMetadatas`) 플레이스홀더 번호를 직접 확인한다.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { DataSource } from 'typeorm'
|
|
17
|
+
|
|
18
|
+
import { Domain, NamingStrategy, entities as shellEntities } from '@things-factory/shell'
|
|
19
|
+
|
|
20
|
+
let dataSource: DataSource
|
|
21
|
+
|
|
22
|
+
/** 서버에 연결하지 않고 Postgres 방언으로 SQL 만 생성할 수 있게 메타데이터만 만든다. */
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
dataSource = new DataSource({
|
|
25
|
+
type: 'postgres',
|
|
26
|
+
host: 'localhost',
|
|
27
|
+
database: 'unused',
|
|
28
|
+
namingStrategy: new NamingStrategy(),
|
|
29
|
+
entities: [...shellEntities],
|
|
30
|
+
logging: false
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
await (dataSource as any).buildMetadatas()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
function placeholderOf(sql: string, column: string): string | undefined {
|
|
37
|
+
return new RegExp(`"${column}" = (\\$\\d+)`).exec(sql)?.[1]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('domains.owner 는 varchar, 사용자 참조는 uuid', () => {
|
|
41
|
+
it('컬럼 타입이 실제로 다르다 (이 검사의 전제)', () => {
|
|
42
|
+
const ownerColumn = dataSource.getMetadata(Domain).columns.find(column => column.databaseName === 'owner')
|
|
43
|
+
const idColumn = dataSource.getMetadata(Domain).columns.find(column => column.databaseName === 'id')
|
|
44
|
+
|
|
45
|
+
expect(idColumn.type).toBe('uuid')
|
|
46
|
+
/* varchar 는 TypeORM 기본값이라 type 이 비어 있다 — uuid 가 아니라는 점이 핵심이다. */
|
|
47
|
+
expect(ownerColumn.type).not.toBe('uuid')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('같은 파라미터를 두 컬럼에 쓰면 Postgres 는 플레이스홀더를 재사용한다 (금지 형태)', () => {
|
|
51
|
+
const [sql] = dataSource
|
|
52
|
+
.getRepository(Domain)
|
|
53
|
+
.createQueryBuilder('DOMAIN')
|
|
54
|
+
.where('DOMAIN.owner = :sameParam')
|
|
55
|
+
.orWhere('DOMAIN.id = :sameParam')
|
|
56
|
+
.setParameter('sameParam', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
|
57
|
+
.getQueryAndParameters()
|
|
58
|
+
|
|
59
|
+
/* 이 형태가 운영에서 `operator does not exist: uuid = text` 를 만든다. */
|
|
60
|
+
expect(placeholderOf(sql, 'owner')).toBe(placeholderOf(sql, 'id'))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('파라미터를 분리하면 각각 독립적으로 추론된다 (요구 형태)', () => {
|
|
64
|
+
const [sql] = dataSource
|
|
65
|
+
.getRepository(Domain)
|
|
66
|
+
.createQueryBuilder('DOMAIN')
|
|
67
|
+
.where('DOMAIN.owner = :ownerId')
|
|
68
|
+
.orWhere('DOMAIN.id = :domainId')
|
|
69
|
+
.setParameter('ownerId', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
|
70
|
+
.setParameter('domainId', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
|
71
|
+
.getQueryAndParameters()
|
|
72
|
+
|
|
73
|
+
expect(placeholderOf(sql, 'owner')).not.toBe(placeholderOf(sql, 'id'))
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('소스 검사 — owner 를 비교하는 쿼리에서 파라미터를 공유하지 않는다', () => {
|
|
78
|
+
/**
|
|
79
|
+
* 쿼리빌더 체인 단위로 잘라, `owner` 비교가 있는 체인에서 같은 파라미터가 두 번 이상
|
|
80
|
+
* 쓰였는지 본다. 실행 없이 소스만 보는 검사라 새로 추가되는 코드도 함께 걸린다.
|
|
81
|
+
*/
|
|
82
|
+
const CHAIN = /createQueryBuilder\([\s\S]{0,4000}?\.(getMany|getOne|getRawMany|getRawOne|getExists|getCount|getManyAndCount)\(\)/g
|
|
83
|
+
|
|
84
|
+
function offendingChains(source: string): string[] {
|
|
85
|
+
const offenders: string[] = []
|
|
86
|
+
|
|
87
|
+
for (const [chain] of source.matchAll(CHAIN)) {
|
|
88
|
+
if (!/\.owner\s*(=|IN)/.test(chain)) {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const names = [...chain.matchAll(/:(?:\.\.\.)?([a-zA-Z_]\w*)/g)].map(match => match[1])
|
|
93
|
+
const repeated = names.filter((name, index) => names.indexOf(name) !== index)
|
|
94
|
+
if (repeated.length) {
|
|
95
|
+
offenders.push(`${chain.slice(0, 60)}… (중복 파라미터: ${[...new Set(repeated)].join(', ')})`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return offenders
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
it('금지 형태를 실제로 검출한다 (검사 자체의 변별력)', () => {
|
|
103
|
+
const bad = `
|
|
104
|
+
createQueryBuilder('DOMAIN')
|
|
105
|
+
.where('DOMAIN.owner = :userId')
|
|
106
|
+
.orWhere('DOMAIN_OWNER.user = :userId')
|
|
107
|
+
.getMany()
|
|
108
|
+
`
|
|
109
|
+
expect(offendingChains(bad)).toHaveLength(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('체크인 판정 코드에 금지 형태가 없다', () => {
|
|
113
|
+
/* auth-base 의 소유권·체크인 조회가 이 함정의 유일한 실제 발생 지점이었다. */
|
|
114
|
+
const target = require('path').resolve(
|
|
115
|
+
__dirname,
|
|
116
|
+
'../../auth-base/server/utils/checkin-domain-facts.ts'
|
|
117
|
+
)
|
|
118
|
+
const source = require('fs').readFileSync(target, 'utf8')
|
|
119
|
+
|
|
120
|
+
expect(offendingChains(source)).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
})
|