@things-factory/auth-base 10.0.0-beta.6 → 10.0.0-beta.67
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-client/apply-domain-theme.js +11 -0
- package/dist-client/apply-domain-theme.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/constants/max-age.js +5 -1
- package/dist-server/constants/max-age.js.map +1 -1
- package/dist-server/middlewares/domain-authenticate-middleware.js +35 -1
- package/dist-server/middlewares/domain-authenticate-middleware.js.map +1 -1
- package/dist-server/middlewares/index.d.ts +2 -1
- package/dist-server/middlewares/index.js.map +1 -1
- package/dist-server/migrations/1745000000000-MigrateDomainOwnersToTable.d.ts +14 -0
- package/dist-server/migrations/1745000000000-MigrateDomainOwnersToTable.js +60 -0
- package/dist-server/migrations/1745000000000-MigrateDomainOwnersToTable.js.map +1 -0
- package/dist-server/service/domain-owner/domain-owner-mutation.d.ts +16 -0
- package/dist-server/service/domain-owner/domain-owner-mutation.js +151 -0
- package/dist-server/service/domain-owner/domain-owner-mutation.js.map +1 -0
- package/dist-server/service/domain-owner/domain-owner-query.d.ts +12 -0
- package/dist-server/service/domain-owner/domain-owner-query.js +70 -0
- package/dist-server/service/domain-owner/domain-owner-query.js.map +1 -0
- package/dist-server/service/domain-owner/domain-owner.d.ts +29 -0
- package/dist-server/service/domain-owner/domain-owner.js +77 -0
- package/dist-server/service/domain-owner/domain-owner.js.map +1 -0
- package/dist-server/service/domain-owner/index.d.ts +5 -0
- package/dist-server/service/domain-owner/index.js +9 -0
- package/dist-server/service/domain-owner/index.js.map +1 -0
- package/dist-server/service/index.d.ts +2 -1
- package/dist-server/service/index.js +6 -2
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/user/user-mutation.js +2 -2
- package/dist-server/service/user/user-mutation.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -5
- package/spec/unit/domain-owner-entity.spec.ts +179 -0
- package/spec/unit/domain-owner-granted.spec.ts +215 -0
- package/spec/unit/domain-owner-migration.spec.ts +236 -0
- package/spec/unit/domain-owner-mutation.spec.ts +337 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.0.0-beta.
|
|
3
|
+
"version": "10.0.0-beta.67",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -31,11 +31,12 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@google-cloud/recaptcha-enterprise": "^5.13.0",
|
|
34
|
+
"@reduxjs/toolkit": "^2.2.5",
|
|
34
35
|
"@simplewebauthn/browser": "^13.0.0",
|
|
35
36
|
"@simplewebauthn/server": "^13.0.0",
|
|
36
|
-
"@things-factory/email-base": "^10.0.0-beta.
|
|
37
|
-
"@things-factory/env": "^10.0.0-beta.
|
|
38
|
-
"@things-factory/shell": "^10.0.0-beta.
|
|
37
|
+
"@things-factory/email-base": "^10.0.0-beta.53",
|
|
38
|
+
"@things-factory/env": "^10.0.0-beta.19",
|
|
39
|
+
"@things-factory/shell": "^10.0.0-beta.53",
|
|
39
40
|
"@things-factory/utils": "^10.0.0-beta.5",
|
|
40
41
|
"@types/webappsec-credential-management": "^0.6.9",
|
|
41
42
|
"jsonwebtoken": "^9.0.0",
|
|
@@ -47,5 +48,5 @@
|
|
|
47
48
|
"passport-jwt": "^4.0.0",
|
|
48
49
|
"passport-local": "^1.0.0"
|
|
49
50
|
},
|
|
50
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "865e7545d5701bb8e0d67cd408f12267e5a0ad47"
|
|
51
52
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DomainOwner Entity Tests
|
|
3
|
+
*
|
|
4
|
+
* DomainOwner 엔티티의 저장/조회/유니크 제약/캐스케이드 테스트.
|
|
5
|
+
* EntitySchema 기반 테스트 DB를 사용하므로 실제 @things-factory/shell 의
|
|
6
|
+
* Domain/User 엔티티 대신 테스트 전용 스키마를 사용한다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { TestDatabase } from '../../../../test/test-database'
|
|
10
|
+
import { withTestTransaction } from '../../../../test/test-context'
|
|
11
|
+
import {
|
|
12
|
+
domainFactory,
|
|
13
|
+
userFactory,
|
|
14
|
+
domainOwnerFactory
|
|
15
|
+
} from '../../../../test/factories'
|
|
16
|
+
import { DomainOwner } from '../../../../test/entities/schemas'
|
|
17
|
+
|
|
18
|
+
describe('DomainOwner Entity', () => {
|
|
19
|
+
let testDb: TestDatabase
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
testDb = TestDatabase.getInstance()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('생성', () => {
|
|
26
|
+
it('기본 DomainOwner 엔트리를 저장할 수 있다', async () => {
|
|
27
|
+
await withTestTransaction(async context => {
|
|
28
|
+
const { tx } = context.state
|
|
29
|
+
|
|
30
|
+
const domain = await domainFactory.create({}, tx)
|
|
31
|
+
const user = await userFactory.create({}, tx)
|
|
32
|
+
|
|
33
|
+
const entry = await domainOwnerFactory.grant(domain, user, {}, tx)
|
|
34
|
+
|
|
35
|
+
expect(entry.id).toBeDefined()
|
|
36
|
+
expect(entry.grantedAt).toBeInstanceOf(Date)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('grantedBy (부여자) 감사 정보를 저장한다', async () => {
|
|
41
|
+
await withTestTransaction(async context => {
|
|
42
|
+
const { tx } = context.state
|
|
43
|
+
|
|
44
|
+
const domain = await domainFactory.create({}, tx)
|
|
45
|
+
const newOwner = await userFactory.create({ name: 'Alice' }, tx)
|
|
46
|
+
const granter = await userFactory.create({ name: 'Bob the Admin' }, tx)
|
|
47
|
+
|
|
48
|
+
const entry = await domainOwnerFactory.grantBy(
|
|
49
|
+
domain,
|
|
50
|
+
newOwner,
|
|
51
|
+
granter,
|
|
52
|
+
{ reason: 'initial setup' },
|
|
53
|
+
tx
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const reloaded = await tx.findOne<DomainOwner>('DomainOwner', {
|
|
57
|
+
where: { id: entry.id },
|
|
58
|
+
relations: ['user', 'grantedBy', 'domain']
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
expect(reloaded?.user.id).toBe(newOwner.id)
|
|
62
|
+
expect(reloaded?.grantedBy?.id).toBe(granter.id)
|
|
63
|
+
expect(reloaded?.domain.id).toBe(domain.id)
|
|
64
|
+
expect(reloaded?.reason).toBe('initial setup')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('reason 은 선택(null)적이다', async () => {
|
|
69
|
+
await withTestTransaction(async context => {
|
|
70
|
+
const { tx } = context.state
|
|
71
|
+
|
|
72
|
+
const domain = await domainFactory.create({}, tx)
|
|
73
|
+
const user = await userFactory.create({}, tx)
|
|
74
|
+
|
|
75
|
+
const entry = await domainOwnerFactory.grant(domain, user, { reason: null as any }, tx)
|
|
76
|
+
|
|
77
|
+
expect(entry.id).toBeDefined()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('유니크 제약', () => {
|
|
83
|
+
it('같은 (domain, user) 조합은 중복 삽입할 수 없다', async () => {
|
|
84
|
+
await withTestTransaction(async context => {
|
|
85
|
+
const { tx } = context.state
|
|
86
|
+
|
|
87
|
+
const domain = await domainFactory.create({}, tx)
|
|
88
|
+
const user = await userFactory.create({}, tx)
|
|
89
|
+
|
|
90
|
+
await domainOwnerFactory.grant(domain, user, {}, tx)
|
|
91
|
+
|
|
92
|
+
await expect(
|
|
93
|
+
domainOwnerFactory.grant(domain, user, { reason: 'duplicate' }, tx)
|
|
94
|
+
).rejects.toThrow()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('같은 user는 서로 다른 domain에는 owner로 등록 가능하다', async () => {
|
|
99
|
+
await withTestTransaction(async context => {
|
|
100
|
+
const { tx } = context.state
|
|
101
|
+
|
|
102
|
+
const domainA = await domainFactory.create({ name: 'A' }, tx)
|
|
103
|
+
const domainB = await domainFactory.create({ name: 'B' }, tx)
|
|
104
|
+
const user = await userFactory.create({}, tx)
|
|
105
|
+
|
|
106
|
+
const a = await domainOwnerFactory.grant(domainA, user, {}, tx)
|
|
107
|
+
const b = await domainOwnerFactory.grant(domainB, user, {}, tx)
|
|
108
|
+
|
|
109
|
+
expect(a.id).not.toBe(b.id)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('같은 domain에 여러 user가 owner로 등록 가능하다 (멀티 오너)', async () => {
|
|
114
|
+
await withTestTransaction(async context => {
|
|
115
|
+
const { tx } = context.state
|
|
116
|
+
|
|
117
|
+
const domain = await domainFactory.create({}, tx)
|
|
118
|
+
const u1 = await userFactory.create({ name: 'U1' }, tx)
|
|
119
|
+
const u2 = await userFactory.create({ name: 'U2' }, tx)
|
|
120
|
+
const u3 = await userFactory.create({ name: 'U3' }, tx)
|
|
121
|
+
|
|
122
|
+
await domainOwnerFactory.grant(domain, u1, {}, tx)
|
|
123
|
+
await domainOwnerFactory.grant(domain, u2, {}, tx)
|
|
124
|
+
await domainOwnerFactory.grant(domain, u3, {}, tx)
|
|
125
|
+
|
|
126
|
+
const count = await tx.count('DomainOwner', {
|
|
127
|
+
where: { domain: { id: domain.id } }
|
|
128
|
+
})
|
|
129
|
+
expect(count).toBe(3)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('CASCADE on delete', () => {
|
|
135
|
+
it('User 삭제 시 관련 DomainOwner 엔트리가 자동 제거된다', async () => {
|
|
136
|
+
// 트랜잭션 밖에서 실행 — CASCADE 동작이 FK 제약에 달려있고
|
|
137
|
+
// sqlite 트랜잭션 롤백에서 깔끔하게 관측하기 어려움.
|
|
138
|
+
const ds = testDb.getDataSource()
|
|
139
|
+
|
|
140
|
+
const domain = await domainFactory.create({ name: 'cascade-user' })
|
|
141
|
+
const user = await userFactory.create({ name: 'to-delete' })
|
|
142
|
+
const entry = await domainOwnerFactory.grant(domain, user)
|
|
143
|
+
|
|
144
|
+
expect(
|
|
145
|
+
await ds.manager.count('DomainOwner', { where: { id: entry.id } })
|
|
146
|
+
).toBe(1)
|
|
147
|
+
|
|
148
|
+
await ds.manager.delete('User', { id: user.id })
|
|
149
|
+
|
|
150
|
+
expect(
|
|
151
|
+
await ds.manager.count('DomainOwner', { where: { id: entry.id } })
|
|
152
|
+
).toBe(0)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('Domain 삭제 시 관련 DomainOwner 엔트리가 자동 제거된다', async () => {
|
|
156
|
+
const ds = testDb.getDataSource()
|
|
157
|
+
|
|
158
|
+
const domain = await domainFactory.create({ name: 'cascade-domain' })
|
|
159
|
+
const u1 = await userFactory.create({ name: 'co-owner-1' })
|
|
160
|
+
const u2 = await userFactory.create({ name: 'co-owner-2' })
|
|
161
|
+
await domainOwnerFactory.grant(domain, u1)
|
|
162
|
+
await domainOwnerFactory.grant(domain, u2)
|
|
163
|
+
|
|
164
|
+
expect(
|
|
165
|
+
await ds.manager.count('DomainOwner', {
|
|
166
|
+
where: { domain: { id: domain.id } }
|
|
167
|
+
})
|
|
168
|
+
).toBe(2)
|
|
169
|
+
|
|
170
|
+
await ds.manager.delete('Domain', { id: domain.id })
|
|
171
|
+
|
|
172
|
+
expect(
|
|
173
|
+
await ds.manager.count('DomainOwner', {
|
|
174
|
+
where: { domain: { id: domain.id } }
|
|
175
|
+
})
|
|
176
|
+
).toBe(0)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* domainOwnerGranted 권한 체크 로직 테스트
|
|
3
|
+
*
|
|
4
|
+
* 실제 함수(`process.domainOwnerGranted`)는 `@things-factory/shell`의
|
|
5
|
+
* getRepository(Domain)을 사용하므로 테스트 환경에서 직접 호출하기 어렵다.
|
|
6
|
+
* 동일한 동작을 하는 로직을 재현해 양쪽 경로(legacy cache + join table)를
|
|
7
|
+
* 모두 검증한다.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { EntityManager } from 'typeorm'
|
|
11
|
+
import { TestDatabase } from '../../../../test/test-database'
|
|
12
|
+
import { withTestTransaction } from '../../../../test/test-context'
|
|
13
|
+
import {
|
|
14
|
+
domainFactory,
|
|
15
|
+
userFactory,
|
|
16
|
+
domainOwnerFactory
|
|
17
|
+
} from '../../../../test/factories'
|
|
18
|
+
import { Domain, User } from '../../../../test/entities/schemas'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `packages/auth-base/server/middlewares/domain-authenticate-middleware.ts`
|
|
22
|
+
* 의 `process.domainOwnerGranted` 와 동일한 로직.
|
|
23
|
+
*/
|
|
24
|
+
async function domainOwnerGranted(
|
|
25
|
+
manager: EntityManager,
|
|
26
|
+
domain: Domain | null | undefined,
|
|
27
|
+
user: User | null | undefined
|
|
28
|
+
): Promise<boolean> {
|
|
29
|
+
if (!user || !domain) return false
|
|
30
|
+
if (domain.owner === user.id) return true
|
|
31
|
+
|
|
32
|
+
return manager
|
|
33
|
+
.createQueryBuilder()
|
|
34
|
+
.select('ow')
|
|
35
|
+
.from('DomainOwner', 'ow')
|
|
36
|
+
.where('ow.domain_id = :domainId AND ow.user_id = :userId', {
|
|
37
|
+
domainId: domain.id,
|
|
38
|
+
userId: user.id
|
|
39
|
+
})
|
|
40
|
+
.getExists()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('domainOwnerGranted 권한 체크', () => {
|
|
44
|
+
let testDb: TestDatabase
|
|
45
|
+
|
|
46
|
+
beforeAll(() => {
|
|
47
|
+
testDb = TestDatabase.getInstance()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('Null/빈 값 방어', () => {
|
|
51
|
+
it('user가 없으면 false', async () => {
|
|
52
|
+
await withTestTransaction(async context => {
|
|
53
|
+
const { tx, domain } = context.state
|
|
54
|
+
expect(await domainOwnerGranted(tx, domain, null)).toBe(false)
|
|
55
|
+
expect(await domainOwnerGranted(tx, domain, undefined)).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('domain이 없으면 false', async () => {
|
|
60
|
+
await withTestTransaction(async context => {
|
|
61
|
+
const { tx, user } = context.state
|
|
62
|
+
expect(await domainOwnerGranted(tx, null, user)).toBe(false)
|
|
63
|
+
expect(await domainOwnerGranted(tx, undefined, user)).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('둘 다 없으면 false', async () => {
|
|
68
|
+
await withTestTransaction(async context => {
|
|
69
|
+
const { tx } = context.state
|
|
70
|
+
expect(await domainOwnerGranted(tx, null, null)).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('(1) 하위 호환: Domain.owner 캐시 필드', () => {
|
|
76
|
+
it('Domain.owner === user.id 이면 true', async () => {
|
|
77
|
+
await withTestTransaction(async context => {
|
|
78
|
+
const { tx } = context.state
|
|
79
|
+
|
|
80
|
+
const user = await userFactory.create({}, tx)
|
|
81
|
+
const domain = await domainFactory.create({ owner: user.id }, tx)
|
|
82
|
+
|
|
83
|
+
expect(await domainOwnerGranted(tx, domain, user)).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('Domain.owner 와 user.id 가 다르고 join 테이블에도 없으면 false', async () => {
|
|
88
|
+
await withTestTransaction(async context => {
|
|
89
|
+
const { tx } = context.state
|
|
90
|
+
|
|
91
|
+
const ownerUser = await userFactory.create({ name: 'owner' }, tx)
|
|
92
|
+
const otherUser = await userFactory.create({ name: 'other' }, tx)
|
|
93
|
+
const domain = await domainFactory.create({ owner: ownerUser.id }, tx)
|
|
94
|
+
|
|
95
|
+
expect(await domainOwnerGranted(tx, domain, otherUser)).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('(2) DomainOwner 조인 테이블', () => {
|
|
101
|
+
it('테이블에 엔트리가 있으면 true (legacy cache 없이도)', async () => {
|
|
102
|
+
await withTestTransaction(async context => {
|
|
103
|
+
const { tx } = context.state
|
|
104
|
+
|
|
105
|
+
const domain = await domainFactory.create({}, tx) // owner = null
|
|
106
|
+
const user = await userFactory.create({}, tx)
|
|
107
|
+
await domainOwnerFactory.grant(domain, user, {}, tx)
|
|
108
|
+
|
|
109
|
+
expect(await domainOwnerGranted(tx, domain, user)).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('엔트리가 없으면 false', async () => {
|
|
114
|
+
await withTestTransaction(async context => {
|
|
115
|
+
const { tx } = context.state
|
|
116
|
+
|
|
117
|
+
const domain = await domainFactory.create({}, tx)
|
|
118
|
+
const user = await userFactory.create({}, tx)
|
|
119
|
+
|
|
120
|
+
expect(await domainOwnerGranted(tx, domain, user)).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('다른 도메인의 owner 엔트리로는 현재 도메인 owner 권한이 생기지 않는다', async () => {
|
|
125
|
+
await withTestTransaction(async context => {
|
|
126
|
+
const { tx } = context.state
|
|
127
|
+
|
|
128
|
+
const domainA = await domainFactory.create({ name: 'A' }, tx)
|
|
129
|
+
const domainB = await domainFactory.create({ name: 'B' }, tx)
|
|
130
|
+
const user = await userFactory.create({}, tx)
|
|
131
|
+
|
|
132
|
+
await domainOwnerFactory.grant(domainA, user, {}, tx)
|
|
133
|
+
|
|
134
|
+
expect(await domainOwnerGranted(tx, domainA, user)).toBe(true)
|
|
135
|
+
expect(await domainOwnerGranted(tx, domainB, user)).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('같은 도메인의 다른 user는 owner가 아니다', async () => {
|
|
140
|
+
await withTestTransaction(async context => {
|
|
141
|
+
const { tx } = context.state
|
|
142
|
+
|
|
143
|
+
const domain = await domainFactory.create({}, tx)
|
|
144
|
+
const ownerUser = await userFactory.create({ name: 'owner' }, tx)
|
|
145
|
+
const memberUser = await userFactory.create({ name: 'member' }, tx)
|
|
146
|
+
|
|
147
|
+
await domainOwnerFactory.grant(domain, ownerUser, {}, tx)
|
|
148
|
+
|
|
149
|
+
expect(await domainOwnerGranted(tx, domain, ownerUser)).toBe(true)
|
|
150
|
+
expect(await domainOwnerGranted(tx, domain, memberUser)).toBe(false)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('멀티 오너 — 각각이 독립적으로 owner로 인정된다', async () => {
|
|
155
|
+
await withTestTransaction(async context => {
|
|
156
|
+
const { tx } = context.state
|
|
157
|
+
|
|
158
|
+
const domain = await domainFactory.create({}, tx)
|
|
159
|
+
const u1 = await userFactory.create({ name: 'U1' }, tx)
|
|
160
|
+
const u2 = await userFactory.create({ name: 'U2' }, tx)
|
|
161
|
+
const u3 = await userFactory.create({ name: 'U3' }, tx)
|
|
162
|
+
|
|
163
|
+
await domainOwnerFactory.grant(domain, u1, {}, tx)
|
|
164
|
+
await domainOwnerFactory.grant(domain, u2, {}, tx)
|
|
165
|
+
await domainOwnerFactory.grant(domain, u3, {}, tx)
|
|
166
|
+
|
|
167
|
+
expect(await domainOwnerGranted(tx, domain, u1)).toBe(true)
|
|
168
|
+
expect(await domainOwnerGranted(tx, domain, u2)).toBe(true)
|
|
169
|
+
expect(await domainOwnerGranted(tx, domain, u3)).toBe(true)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('양쪽 경로 OR 동작', () => {
|
|
175
|
+
it('legacy cache 경로만 만족해도 true', async () => {
|
|
176
|
+
await withTestTransaction(async context => {
|
|
177
|
+
const { tx } = context.state
|
|
178
|
+
|
|
179
|
+
const user = await userFactory.create({}, tx)
|
|
180
|
+
const domain = await domainFactory.create({ owner: user.id }, tx)
|
|
181
|
+
// join table에 엔트리 없음
|
|
182
|
+
|
|
183
|
+
expect(await domainOwnerGranted(tx, domain, user)).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('join table 경로만 만족해도 true (cache가 다른 user여도)', async () => {
|
|
188
|
+
await withTestTransaction(async context => {
|
|
189
|
+
const { tx } = context.state
|
|
190
|
+
|
|
191
|
+
const cachedUser = await userFactory.create({ name: 'cached' }, tx)
|
|
192
|
+
const newOwner = await userFactory.create({ name: 'new-owner' }, tx)
|
|
193
|
+
const domain = await domainFactory.create({ owner: cachedUser.id }, tx)
|
|
194
|
+
|
|
195
|
+
await domainOwnerFactory.grant(domain, newOwner, {}, tx)
|
|
196
|
+
|
|
197
|
+
expect(await domainOwnerGranted(tx, domain, newOwner)).toBe(true)
|
|
198
|
+
// 기존 캐시된 유저도 여전히 owner
|
|
199
|
+
expect(await domainOwnerGranted(tx, domain, cachedUser)).toBe(true)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('양쪽 모두 만족하면 true (중복 허용 — cache와 table에 같은 사람)', async () => {
|
|
204
|
+
await withTestTransaction(async context => {
|
|
205
|
+
const { tx } = context.state
|
|
206
|
+
|
|
207
|
+
const user = await userFactory.create({}, tx)
|
|
208
|
+
const domain = await domainFactory.create({ owner: user.id }, tx)
|
|
209
|
+
await domainOwnerFactory.grant(domain, user, {}, tx)
|
|
210
|
+
|
|
211
|
+
expect(await domainOwnerGranted(tx, domain, user)).toBe(true)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
})
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MigrateDomainOwnersToTable 마이그레이션 로직 테스트
|
|
3
|
+
*
|
|
4
|
+
* 마이그레이션의 핵심 동작:
|
|
5
|
+
* - Domain.owner가 있는 모든 도메인을 순회
|
|
6
|
+
* - DomainOwner 테이블에 동일 (domain, user) 쌍이 없으면 삽입
|
|
7
|
+
* - 이미 존재하면 skip (idempotent)
|
|
8
|
+
* - Domain.owner 는 건드리지 않음 (데이터 유실 방지)
|
|
9
|
+
*
|
|
10
|
+
* 실제 마이그레이션 클래스는 `@things-factory/shell` 의 getRepository에
|
|
11
|
+
* 의존하므로, 동일한 로직을 테스트 스키마 기반으로 재현한다.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { EntityManager } from 'typeorm'
|
|
15
|
+
import { TestDatabase } from '../../../../test/test-database'
|
|
16
|
+
import {
|
|
17
|
+
domainFactory,
|
|
18
|
+
userFactory,
|
|
19
|
+
domainOwnerFactory
|
|
20
|
+
} from '../../../../test/factories'
|
|
21
|
+
import { Domain, DomainOwner, User } from '../../../../test/entities/schemas'
|
|
22
|
+
|
|
23
|
+
const MIGRATION_REASON = 'migrated from legacy single-owner field'
|
|
24
|
+
|
|
25
|
+
/** up: legacy Domain.owner → DomainOwner 테이블 이관 */
|
|
26
|
+
async function migrationUp(
|
|
27
|
+
manager: EntityManager
|
|
28
|
+
): Promise<{ migrated: number; skipped: number }> {
|
|
29
|
+
const domainRepo = manager.getRepository<Domain>('Domain')
|
|
30
|
+
const ownerRepo = manager.getRepository<DomainOwner>('DomainOwner')
|
|
31
|
+
|
|
32
|
+
const domains = await domainRepo
|
|
33
|
+
.createQueryBuilder('d')
|
|
34
|
+
.where('d.owner IS NOT NULL')
|
|
35
|
+
.getMany()
|
|
36
|
+
|
|
37
|
+
let migrated = 0
|
|
38
|
+
let skipped = 0
|
|
39
|
+
|
|
40
|
+
for (const domain of domains) {
|
|
41
|
+
if (!domain.owner) continue
|
|
42
|
+
|
|
43
|
+
const existing = await ownerRepo.findOne({
|
|
44
|
+
where: { domain: { id: domain.id }, user: { id: domain.owner } }
|
|
45
|
+
})
|
|
46
|
+
if (existing) {
|
|
47
|
+
skipped++
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const entry = ownerRepo.create({
|
|
52
|
+
domain: { id: domain.id } as Domain,
|
|
53
|
+
user: { id: domain.owner } as unknown as User,
|
|
54
|
+
reason: MIGRATION_REASON
|
|
55
|
+
} as Partial<DomainOwner>)
|
|
56
|
+
await ownerRepo.save(entry)
|
|
57
|
+
migrated++
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { migrated, skipped }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** down: reason 이 'migrated from legacy...'인 엔트리만 삭제 */
|
|
64
|
+
async function migrationDown(manager: EntityManager): Promise<number> {
|
|
65
|
+
const result = await manager
|
|
66
|
+
.createQueryBuilder()
|
|
67
|
+
.delete()
|
|
68
|
+
.from('DomainOwner')
|
|
69
|
+
.where('reason = :reason', { reason: MIGRATION_REASON })
|
|
70
|
+
.execute()
|
|
71
|
+
return result.affected ?? 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('MigrateDomainOwnersToTable', () => {
|
|
75
|
+
let testDb: TestDatabase
|
|
76
|
+
|
|
77
|
+
beforeAll(() => {
|
|
78
|
+
testDb = TestDatabase.getInstance()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('up()', () => {
|
|
82
|
+
it('Domain.owner 가 있는 도메인을 DomainOwner 테이블에 이관한다', async () => {
|
|
83
|
+
const ds = testDb.getDataSource()
|
|
84
|
+
|
|
85
|
+
const u1 = await userFactory.create({ name: 'legacy-owner-1' })
|
|
86
|
+
const u2 = await userFactory.create({ name: 'legacy-owner-2' })
|
|
87
|
+
await domainFactory.create({ name: 'D1', owner: u1.id })
|
|
88
|
+
await domainFactory.create({ name: 'D2', owner: u2.id })
|
|
89
|
+
|
|
90
|
+
const result = await migrationUp(ds.manager)
|
|
91
|
+
|
|
92
|
+
expect(result.migrated).toBeGreaterThanOrEqual(2)
|
|
93
|
+
|
|
94
|
+
const total = await ds.manager.count('DomainOwner', {
|
|
95
|
+
where: { reason: MIGRATION_REASON }
|
|
96
|
+
})
|
|
97
|
+
expect(total).toBeGreaterThanOrEqual(2)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('Domain.owner 가 null인 도메인은 건드리지 않는다', async () => {
|
|
101
|
+
const ds = testDb.getDataSource()
|
|
102
|
+
|
|
103
|
+
await domainFactory.create({ name: 'no-owner-domain', owner: null as any })
|
|
104
|
+
|
|
105
|
+
const beforeCount = await ds.manager.count('DomainOwner')
|
|
106
|
+
await migrationUp(ds.manager)
|
|
107
|
+
const afterCount = await ds.manager.count('DomainOwner')
|
|
108
|
+
|
|
109
|
+
// no-owner-domain 에 대해서는 아무것도 생성되지 않음
|
|
110
|
+
expect(afterCount).toBe(beforeCount)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('이미 DomainOwner 엔트리가 있는 경우 skip 된다 (idempotent)', async () => {
|
|
114
|
+
const ds = testDb.getDataSource()
|
|
115
|
+
|
|
116
|
+
const user = await userFactory.create({ name: 'already-migrated' })
|
|
117
|
+
const domain = await domainFactory.create({
|
|
118
|
+
name: 'idempotent-domain',
|
|
119
|
+
owner: user.id
|
|
120
|
+
})
|
|
121
|
+
await domainOwnerFactory.grant(domain, user, { reason: 'added manually' })
|
|
122
|
+
|
|
123
|
+
const result = await migrationUp(ds.manager)
|
|
124
|
+
expect(result.skipped).toBeGreaterThanOrEqual(1)
|
|
125
|
+
|
|
126
|
+
// 엔트리 수는 하나로 유지 (중복 생성 안 됨)
|
|
127
|
+
const count = await ds.manager.count('DomainOwner', {
|
|
128
|
+
where: {
|
|
129
|
+
domain: { id: domain.id },
|
|
130
|
+
user: { id: user.id }
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
expect(count).toBe(1)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('두 번 연속 실행해도 안전하다 (진정한 idempotency)', async () => {
|
|
137
|
+
const ds = testDb.getDataSource()
|
|
138
|
+
|
|
139
|
+
const user = await userFactory.create({ name: 'twice-migrated' })
|
|
140
|
+
await domainFactory.create({ name: 'twice-domain', owner: user.id })
|
|
141
|
+
|
|
142
|
+
const first = await migrationUp(ds.manager)
|
|
143
|
+
const totalAfterFirst = await ds.manager.count('DomainOwner')
|
|
144
|
+
|
|
145
|
+
const second = await migrationUp(ds.manager)
|
|
146
|
+
const totalAfterSecond = await ds.manager.count('DomainOwner')
|
|
147
|
+
|
|
148
|
+
expect(second.migrated).toBe(0)
|
|
149
|
+
expect(totalAfterSecond).toBe(totalAfterFirst)
|
|
150
|
+
expect(first.migrated).toBeGreaterThan(0)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('down()', () => {
|
|
155
|
+
it("reason이 'migrated from legacy single-owner field' 인 엔트리만 삭제한다", async () => {
|
|
156
|
+
const ds = testDb.getDataSource()
|
|
157
|
+
|
|
158
|
+
const user = await userFactory.create({ name: 'mixed-reason-user' })
|
|
159
|
+
const domain = await domainFactory.create({
|
|
160
|
+
name: 'mixed-reason-domain',
|
|
161
|
+
owner: user.id
|
|
162
|
+
})
|
|
163
|
+
// 마이그레이션으로 생성된 엔트리
|
|
164
|
+
await migrationUp(ds.manager)
|
|
165
|
+
// 다른 reason 으로 수동 추가된 엔트리
|
|
166
|
+
const otherUser = await userFactory.create({ name: 'manual' })
|
|
167
|
+
await domainOwnerFactory.grant(domain, otherUser, { reason: 'manually added by admin' })
|
|
168
|
+
|
|
169
|
+
const beforeMigrated = await ds.manager.count('DomainOwner', {
|
|
170
|
+
where: { reason: MIGRATION_REASON }
|
|
171
|
+
})
|
|
172
|
+
const beforeOther = await ds.manager.count('DomainOwner', {
|
|
173
|
+
where: { reason: 'manually added by admin' }
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
expect(beforeMigrated).toBeGreaterThanOrEqual(1)
|
|
177
|
+
expect(beforeOther).toBe(1)
|
|
178
|
+
|
|
179
|
+
await migrationDown(ds.manager)
|
|
180
|
+
|
|
181
|
+
const afterMigrated = await ds.manager.count('DomainOwner', {
|
|
182
|
+
where: { reason: MIGRATION_REASON }
|
|
183
|
+
})
|
|
184
|
+
const afterOther = await ds.manager.count('DomainOwner', {
|
|
185
|
+
where: { reason: 'manually added by admin' }
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
expect(afterMigrated).toBe(0)
|
|
189
|
+
expect(afterOther).toBe(1) // 수동 추가된 엔트리는 보존
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('Domain.owner 필드는 down() 에서 건드리지 않는다 (데이터 유실 없음)', async () => {
|
|
193
|
+
const ds = testDb.getDataSource()
|
|
194
|
+
|
|
195
|
+
const user = await userFactory.create({ name: 'preserved' })
|
|
196
|
+
const domain = await domainFactory.create({
|
|
197
|
+
name: 'preserved-domain',
|
|
198
|
+
owner: user.id
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
await migrationUp(ds.manager)
|
|
202
|
+
await migrationDown(ds.manager)
|
|
203
|
+
|
|
204
|
+
const reloaded = await ds.manager.findOne<Domain>('Domain', {
|
|
205
|
+
where: { id: domain.id }
|
|
206
|
+
})
|
|
207
|
+
expect(reloaded?.owner).toBe(user.id)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('up → down → up 왕복', () => {
|
|
212
|
+
it('왕복 후 일관성 유지', async () => {
|
|
213
|
+
const ds = testDb.getDataSource()
|
|
214
|
+
|
|
215
|
+
const user = await userFactory.create({ name: 'roundtrip' })
|
|
216
|
+
await domainFactory.create({ name: 'roundtrip-domain', owner: user.id })
|
|
217
|
+
|
|
218
|
+
await migrationUp(ds.manager)
|
|
219
|
+
const afterUp1 = await ds.manager.count('DomainOwner', {
|
|
220
|
+
where: { reason: MIGRATION_REASON }
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
await migrationDown(ds.manager)
|
|
224
|
+
const afterDown = await ds.manager.count('DomainOwner', {
|
|
225
|
+
where: { reason: MIGRATION_REASON }
|
|
226
|
+
})
|
|
227
|
+
expect(afterDown).toBe(0)
|
|
228
|
+
|
|
229
|
+
await migrationUp(ds.manager)
|
|
230
|
+
const afterUp2 = await ds.manager.count('DomainOwner', {
|
|
231
|
+
where: { reason: MIGRATION_REASON }
|
|
232
|
+
})
|
|
233
|
+
expect(afterUp2).toBe(afterUp1)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
})
|