@things-factory/auth-base 10.0.0-beta.5 → 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.
Files changed (33) hide show
  1. package/dist-client/apply-domain-theme.js +11 -0
  2. package/dist-client/apply-domain-theme.js.map +1 -1
  3. package/dist-client/tsconfig.tsbuildinfo +1 -1
  4. package/dist-server/middlewares/domain-authenticate-middleware.js +35 -1
  5. package/dist-server/middlewares/domain-authenticate-middleware.js.map +1 -1
  6. package/dist-server/middlewares/index.d.ts +2 -1
  7. package/dist-server/middlewares/index.js.map +1 -1
  8. package/dist-server/migrations/1745000000000-MigrateDomainOwnersToTable.d.ts +14 -0
  9. package/dist-server/migrations/1745000000000-MigrateDomainOwnersToTable.js +60 -0
  10. package/dist-server/migrations/1745000000000-MigrateDomainOwnersToTable.js.map +1 -0
  11. package/dist-server/service/domain-owner/domain-owner-mutation.d.ts +16 -0
  12. package/dist-server/service/domain-owner/domain-owner-mutation.js +151 -0
  13. package/dist-server/service/domain-owner/domain-owner-mutation.js.map +1 -0
  14. package/dist-server/service/domain-owner/domain-owner-query.d.ts +12 -0
  15. package/dist-server/service/domain-owner/domain-owner-query.js +70 -0
  16. package/dist-server/service/domain-owner/domain-owner-query.js.map +1 -0
  17. package/dist-server/service/domain-owner/domain-owner.d.ts +29 -0
  18. package/dist-server/service/domain-owner/domain-owner.js +77 -0
  19. package/dist-server/service/domain-owner/domain-owner.js.map +1 -0
  20. package/dist-server/service/domain-owner/index.d.ts +5 -0
  21. package/dist-server/service/domain-owner/index.js +9 -0
  22. package/dist-server/service/domain-owner/index.js.map +1 -0
  23. package/dist-server/service/index.d.ts +2 -1
  24. package/dist-server/service/index.js +6 -2
  25. package/dist-server/service/index.js.map +1 -1
  26. package/dist-server/service/user/user-mutation.js +2 -2
  27. package/dist-server/service/user/user-mutation.js.map +1 -1
  28. package/dist-server/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +6 -5
  30. package/spec/unit/domain-owner-entity.spec.ts +179 -0
  31. package/spec/unit/domain-owner-granted.spec.ts +215 -0
  32. package/spec/unit/domain-owner-migration.spec.ts +236 -0
  33. 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
+ })