@things-factory/auth-base 10.0.0-beta.53 → 10.0.0-beta.57
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/middlewares/domain-authenticate-middleware.js +35 -1
- package/dist-server/middlewares/domain-authenticate-middleware.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/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- 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
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DomainOwner Mutation 로직 테스트
|
|
3
|
+
*
|
|
4
|
+
* addDomainOwner / removeDomainOwner mutation 의 핵심 비즈니스 로직
|
|
5
|
+
* (멤버 확인, 중복 방지, 마지막 오너 보호, legacy cache 동기화)을
|
|
6
|
+
* 테스트 엔티티 스키마로 재현해 검증한다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { EntityManager } from 'typeorm'
|
|
10
|
+
import { TestDatabase } from '../../../../test/test-database'
|
|
11
|
+
import {
|
|
12
|
+
domainFactory,
|
|
13
|
+
userFactory,
|
|
14
|
+
domainOwnerFactory
|
|
15
|
+
} from '../../../../test/factories'
|
|
16
|
+
import { Domain, DomainOwner, User } from '../../../../test/entities/schemas'
|
|
17
|
+
|
|
18
|
+
// ============================================================
|
|
19
|
+
// 테스트용 헬퍼: source 로직을 테스트 스키마로 재현
|
|
20
|
+
// ============================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* addDomainOwner 의 핵심 로직
|
|
24
|
+
* - 대상 유저가 domain 멤버인지 확인
|
|
25
|
+
* - 이미 owner인지 확인
|
|
26
|
+
* - 엔트리 생성
|
|
27
|
+
* - Domain.owner 캐시가 비어있으면 세팅
|
|
28
|
+
*/
|
|
29
|
+
async function addDomainOwnerLogic(
|
|
30
|
+
manager: EntityManager,
|
|
31
|
+
domain: Domain,
|
|
32
|
+
targetUser: User,
|
|
33
|
+
actor: User,
|
|
34
|
+
options: { isMember: boolean; reason?: string }
|
|
35
|
+
): Promise<DomainOwner> {
|
|
36
|
+
if (!options.isMember) {
|
|
37
|
+
throw new Error(`User '${targetUser.name}' is not a member of this domain.`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ownerRepo = manager.getRepository<DomainOwner>('DomainOwner')
|
|
41
|
+
|
|
42
|
+
const existing = await ownerRepo.findOne({
|
|
43
|
+
where: { domain: { id: domain.id }, user: { id: targetUser.id } }
|
|
44
|
+
})
|
|
45
|
+
if (existing) {
|
|
46
|
+
throw new Error(`User '${targetUser.name}' is already an owner of this domain.`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const entry = ownerRepo.create({
|
|
50
|
+
domain: { id: domain.id } as Domain,
|
|
51
|
+
user: { id: targetUser.id } as User,
|
|
52
|
+
grantedBy: actor,
|
|
53
|
+
reason: options.reason
|
|
54
|
+
} as Partial<DomainOwner>)
|
|
55
|
+
const saved = await ownerRepo.save(entry)
|
|
56
|
+
|
|
57
|
+
if (!domain.owner) {
|
|
58
|
+
await manager.update('Domain', domain.id, { owner: targetUser.id })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return saved
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* removeDomainOwner 의 핵심 로직
|
|
66
|
+
* - 대상 유저의 엔트리 혹은 legacy owner 여부 확인
|
|
67
|
+
* - 남을 owner가 1명 이상 보장
|
|
68
|
+
* - legacy cache가 제거 대상이면 남은 오너 중 하나로 재설정
|
|
69
|
+
*/
|
|
70
|
+
async function removeDomainOwnerLogic(
|
|
71
|
+
manager: EntityManager,
|
|
72
|
+
domain: Domain,
|
|
73
|
+
targetUser: User
|
|
74
|
+
): Promise<boolean> {
|
|
75
|
+
const ownerRepo = manager.getRepository<DomainOwner>('DomainOwner')
|
|
76
|
+
|
|
77
|
+
const entry = await ownerRepo.findOne({
|
|
78
|
+
where: { domain: { id: domain.id }, user: { id: targetUser.id } }
|
|
79
|
+
})
|
|
80
|
+
const hasLegacyOwner = domain.owner === targetUser.id
|
|
81
|
+
|
|
82
|
+
if (!entry && !hasLegacyOwner) {
|
|
83
|
+
throw new Error(`User '${targetUser.name}' is not an owner of this domain.`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ownerCount = await ownerRepo.count({
|
|
87
|
+
where: { domain: { id: domain.id } }
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
let remaining = ownerCount
|
|
91
|
+
if (entry) remaining -= 1
|
|
92
|
+
if (domain.owner && domain.owner !== targetUser.id) {
|
|
93
|
+
const legacyInTable = await ownerRepo.findOne({
|
|
94
|
+
where: { domain: { id: domain.id }, user: { id: domain.owner } }
|
|
95
|
+
})
|
|
96
|
+
if (!legacyInTable) remaining += 1
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (remaining < 1) {
|
|
100
|
+
throw new Error('Cannot remove the last owner of a domain. Add another owner first.')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (entry) {
|
|
104
|
+
await ownerRepo.delete(entry.id)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (hasLegacyOwner) {
|
|
108
|
+
const next = await ownerRepo.findOne({
|
|
109
|
+
where: { domain: { id: domain.id } },
|
|
110
|
+
order: { grantedAt: 'ASC' }
|
|
111
|
+
})
|
|
112
|
+
const nextOwnerId = next
|
|
113
|
+
? (await manager.findOne<DomainOwner>('DomainOwner', {
|
|
114
|
+
where: { id: next.id },
|
|
115
|
+
relations: ['user']
|
|
116
|
+
}))?.user.id ?? null
|
|
117
|
+
: null
|
|
118
|
+
await manager.update('Domain', domain.id, { owner: nextOwnerId })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================
|
|
125
|
+
// 테스트
|
|
126
|
+
// ============================================================
|
|
127
|
+
|
|
128
|
+
describe('addDomainOwner 로직', () => {
|
|
129
|
+
let testDb: TestDatabase
|
|
130
|
+
|
|
131
|
+
beforeAll(() => {
|
|
132
|
+
testDb = TestDatabase.getInstance()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('도메인 멤버를 owner로 추가할 수 있다', async () => {
|
|
136
|
+
const ds = testDb.getDataSource()
|
|
137
|
+
const domain = await domainFactory.create({})
|
|
138
|
+
const actor = await userFactory.create({ name: 'actor' })
|
|
139
|
+
const target = await userFactory.create({ name: 'target' })
|
|
140
|
+
|
|
141
|
+
const entry = await addDomainOwnerLogic(ds.manager, domain, target, actor, {
|
|
142
|
+
isMember: true
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
expect(entry.id).toBeDefined()
|
|
146
|
+
|
|
147
|
+
const reloaded = await ds.manager.findOne<DomainOwner>('DomainOwner', {
|
|
148
|
+
where: { id: entry.id },
|
|
149
|
+
relations: ['user', 'grantedBy']
|
|
150
|
+
})
|
|
151
|
+
expect(reloaded?.user.id).toBe(target.id)
|
|
152
|
+
expect(reloaded?.grantedBy?.id).toBe(actor.id)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('Domain.owner 캐시가 비어있으면 추가된 user로 세팅한다', async () => {
|
|
156
|
+
const ds = testDb.getDataSource()
|
|
157
|
+
const domain = await domainFactory.create({ owner: null as any })
|
|
158
|
+
const actor = await userFactory.create({ name: 'actor' })
|
|
159
|
+
const target = await userFactory.create({ name: 'target' })
|
|
160
|
+
|
|
161
|
+
await addDomainOwnerLogic(ds.manager, domain, target, actor, {
|
|
162
|
+
isMember: true
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const reloadedDomain = await ds.manager.findOne<Domain>('Domain', {
|
|
166
|
+
where: { id: domain.id }
|
|
167
|
+
})
|
|
168
|
+
expect(reloadedDomain?.owner).toBe(target.id)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('Domain.owner 캐시가 이미 있으면 덮어쓰지 않는다', async () => {
|
|
172
|
+
const ds = testDb.getDataSource()
|
|
173
|
+
const existingOwner = await userFactory.create({ name: 'existing' })
|
|
174
|
+
const domain = await domainFactory.create({ owner: existingOwner.id })
|
|
175
|
+
const actor = await userFactory.create({ name: 'actor' })
|
|
176
|
+
const target = await userFactory.create({ name: 'target' })
|
|
177
|
+
|
|
178
|
+
await addDomainOwnerLogic(ds.manager, domain, target, actor, {
|
|
179
|
+
isMember: true
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const reloadedDomain = await ds.manager.findOne<Domain>('Domain', {
|
|
183
|
+
where: { id: domain.id }
|
|
184
|
+
})
|
|
185
|
+
expect(reloadedDomain?.owner).toBe(existingOwner.id)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('도메인 비멤버는 owner로 추가할 수 없다', async () => {
|
|
189
|
+
const ds = testDb.getDataSource()
|
|
190
|
+
const domain = await domainFactory.create({})
|
|
191
|
+
const actor = await userFactory.create({ name: 'actor' })
|
|
192
|
+
const outsider = await userFactory.create({ name: 'outsider' })
|
|
193
|
+
|
|
194
|
+
await expect(
|
|
195
|
+
addDomainOwnerLogic(ds.manager, domain, outsider, actor, { isMember: false })
|
|
196
|
+
).rejects.toThrow(/not a member/)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('이미 owner인 user를 중복 추가할 수 없다', async () => {
|
|
200
|
+
const ds = testDb.getDataSource()
|
|
201
|
+
const domain = await domainFactory.create({})
|
|
202
|
+
const actor = await userFactory.create({ name: 'actor' })
|
|
203
|
+
const target = await userFactory.create({ name: 'target' })
|
|
204
|
+
|
|
205
|
+
await addDomainOwnerLogic(ds.manager, domain, target, actor, { isMember: true })
|
|
206
|
+
|
|
207
|
+
await expect(
|
|
208
|
+
addDomainOwnerLogic(ds.manager, domain, target, actor, { isMember: true })
|
|
209
|
+
).rejects.toThrow(/already an owner/)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('removeDomainOwner 로직', () => {
|
|
214
|
+
let testDb: TestDatabase
|
|
215
|
+
|
|
216
|
+
beforeAll(() => {
|
|
217
|
+
testDb = TestDatabase.getInstance()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('여러 owner 중 한 명을 제거할 수 있다', async () => {
|
|
221
|
+
const ds = testDb.getDataSource()
|
|
222
|
+
const domain = await domainFactory.create({})
|
|
223
|
+
const u1 = await userFactory.create({ name: 'U1' })
|
|
224
|
+
const u2 = await userFactory.create({ name: 'U2' })
|
|
225
|
+
await domainOwnerFactory.grant(domain, u1)
|
|
226
|
+
await domainOwnerFactory.grant(domain, u2)
|
|
227
|
+
|
|
228
|
+
const result = await removeDomainOwnerLogic(ds.manager, domain, u2)
|
|
229
|
+
expect(result).toBe(true)
|
|
230
|
+
|
|
231
|
+
const remaining = await ds.manager.count('DomainOwner', {
|
|
232
|
+
where: { domain: { id: domain.id } }
|
|
233
|
+
})
|
|
234
|
+
expect(remaining).toBe(1)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('마지막 남은 owner는 제거할 수 없다 (최소 1명 유지)', async () => {
|
|
238
|
+
const ds = testDb.getDataSource()
|
|
239
|
+
const domain = await domainFactory.create({})
|
|
240
|
+
const soleOwner = await userFactory.create({ name: 'sole' })
|
|
241
|
+
await domainOwnerFactory.grant(domain, soleOwner)
|
|
242
|
+
|
|
243
|
+
await expect(
|
|
244
|
+
removeDomainOwnerLogic(ds.manager, domain, soleOwner)
|
|
245
|
+
).rejects.toThrow(/last owner/)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('legacy cache 만 있고 join 테이블에 없어도 "owner"로 인정되어 제거 거부 (마지막 1인)', async () => {
|
|
249
|
+
const ds = testDb.getDataSource()
|
|
250
|
+
const legacyOwner = await userFactory.create({ name: 'legacy' })
|
|
251
|
+
const domain = await domainFactory.create({ owner: legacyOwner.id })
|
|
252
|
+
// join table 에 엔트리 없음
|
|
253
|
+
|
|
254
|
+
await expect(
|
|
255
|
+
removeDomainOwnerLogic(ds.manager, domain, legacyOwner)
|
|
256
|
+
).rejects.toThrow(/last owner/)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('legacy cache + 다른 오너가 join 테이블에 있으면 legacy 제거 가능', async () => {
|
|
260
|
+
const ds = testDb.getDataSource()
|
|
261
|
+
const legacyOwner = await userFactory.create({ name: 'legacy' })
|
|
262
|
+
const newOwner = await userFactory.create({ name: 'new' })
|
|
263
|
+
const domain = await domainFactory.create({ owner: legacyOwner.id })
|
|
264
|
+
await domainOwnerFactory.grant(domain, newOwner)
|
|
265
|
+
|
|
266
|
+
await removeDomainOwnerLogic(ds.manager, domain, legacyOwner)
|
|
267
|
+
|
|
268
|
+
const reloadedDomain = await ds.manager.findOne<Domain>('Domain', {
|
|
269
|
+
where: { id: domain.id }
|
|
270
|
+
})
|
|
271
|
+
// legacy cache 는 남은 오너(newOwner)로 재설정되어야 함
|
|
272
|
+
expect(reloadedDomain?.owner).toBe(newOwner.id)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('legacy cache 대상 제거 시 남은 DomainOwner 중 한 명으로 cache 재설정', async () => {
|
|
276
|
+
const ds = testDb.getDataSource()
|
|
277
|
+
const u1 = await userFactory.create({ name: 'U1' })
|
|
278
|
+
const u2 = await userFactory.create({ name: 'U2' })
|
|
279
|
+
const u3 = await userFactory.create({ name: 'U3' })
|
|
280
|
+
const domain = await domainFactory.create({ owner: u1.id })
|
|
281
|
+
await domainOwnerFactory.grant(domain, u1)
|
|
282
|
+
await domainOwnerFactory.grant(domain, u2)
|
|
283
|
+
await domainOwnerFactory.grant(domain, u3)
|
|
284
|
+
|
|
285
|
+
await removeDomainOwnerLogic(ds.manager, domain, u1)
|
|
286
|
+
|
|
287
|
+
const reloadedDomain = await ds.manager.findOne<Domain>('Domain', {
|
|
288
|
+
where: { id: domain.id }
|
|
289
|
+
})
|
|
290
|
+
// SQLite datetime 은 초 단위 해상도라 grantedAt 이 동일할 수 있음 → 순서 비결정적.
|
|
291
|
+
// 핵심 불변조건: cache 는 u1 이 아니고, 남은 오너 (u2 또는 u3) 중 하나로 갱신됨.
|
|
292
|
+
expect(reloadedDomain?.owner).not.toBe(u1.id)
|
|
293
|
+
expect([u2.id, u3.id]).toContain(reloadedDomain?.owner)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('owner가 아닌 user는 제거할 수 없다', async () => {
|
|
297
|
+
const ds = testDb.getDataSource()
|
|
298
|
+
const domain = await domainFactory.create({})
|
|
299
|
+
const owner = await userFactory.create({ name: 'owner' })
|
|
300
|
+
const other = await userFactory.create({ name: 'other' })
|
|
301
|
+
await domainOwnerFactory.grant(domain, owner)
|
|
302
|
+
|
|
303
|
+
await expect(
|
|
304
|
+
removeDomainOwnerLogic(ds.manager, domain, other)
|
|
305
|
+
).rejects.toThrow(/not an owner/)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('legacy cache 만 있는 마지막 오너 + 새 오너 추가 후에는 legacy 제거 가능', async () => {
|
|
309
|
+
const ds = testDb.getDataSource()
|
|
310
|
+
const legacy = await userFactory.create({ name: 'legacy' })
|
|
311
|
+
const domain = await domainFactory.create({ owner: legacy.id })
|
|
312
|
+
// 이 시점에서 legacy는 cache로만 유일한 owner → 제거 불가
|
|
313
|
+
await expect(
|
|
314
|
+
removeDomainOwnerLogic(ds.manager, domain, legacy)
|
|
315
|
+
).rejects.toThrow(/last owner/)
|
|
316
|
+
|
|
317
|
+
// 새 오너를 추가하면
|
|
318
|
+
const newOwner = await userFactory.create({ name: 'new' })
|
|
319
|
+
const actor = await userFactory.create({ name: 'actor' })
|
|
320
|
+
await addDomainOwnerLogic(ds.manager, domain, newOwner, actor, { isMember: true })
|
|
321
|
+
|
|
322
|
+
// legacy를 제거할 수 있음 (remaining = legacy 1 + new 1 - 1 = 1)
|
|
323
|
+
// NOTE: addDomainOwnerLogic은 domain.owner가 이미 legacy.id 이므로 덮어쓰지 않음
|
|
324
|
+
// 따라서 domain 은 여전히 legacy.id 가 cache
|
|
325
|
+
const reloadedDomain = await ds.manager.findOne<Domain>('Domain', {
|
|
326
|
+
where: { id: domain.id }
|
|
327
|
+
})
|
|
328
|
+
expect(reloadedDomain!.owner).toBe(legacy.id)
|
|
329
|
+
|
|
330
|
+
await removeDomainOwnerLogic(ds.manager, reloadedDomain!, legacy)
|
|
331
|
+
|
|
332
|
+
const finalDomain = await ds.manager.findOne<Domain>('Domain', {
|
|
333
|
+
where: { id: domain.id }
|
|
334
|
+
})
|
|
335
|
+
expect(finalDomain?.owner).toBe(newOwner.id)
|
|
336
|
+
})
|
|
337
|
+
})
|