@things-factory/auth-base 10.1.3 → 10.1.6
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/auth.d.ts +9 -1
- package/dist-client/auth.js +12 -3
- package/dist-client/auth.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/controllers/profile.d.ts +1 -0
- package/dist-server/controllers/signin.js +33 -12
- package/dist-server/controllers/signin.js.map +1 -1
- package/dist-server/controllers/unlock-user.js +21 -2
- package/dist-server/controllers/unlock-user.js.map +1 -1
- package/dist-server/router/auth-public-process-router.js +32 -5
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/service/privilege/privilege-directive.d.ts +46 -0
- package/dist-server/service/privilege/privilege-directive.js +87 -6
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/service/role/index.d.ts +2 -1
- package/dist-server/service/role/index.js +2 -1
- package/dist-server/service/role/index.js.map +1 -1
- package/dist-server/service/role/role-mutation.d.ts +17 -1
- package/dist-server/service/role/role-mutation.js +126 -7
- package/dist-server/service/role/role-mutation.js.map +1 -1
- package/dist-server/service/role/role-privilege-query.d.ts +14 -0
- package/dist-server/service/role/role-privilege-query.js +37 -0
- package/dist-server/service/role/role-privilege-query.js.map +1 -0
- package/dist-server/service/role/role-query.d.ts +18 -0
- package/dist-server/service/role/role-query.js +38 -22
- package/dist-server/service/role/role-query.js.map +1 -1
- package/dist-server/service/user/user.d.ts +1 -0
- package/dist-server/service/user/user.js +55 -13
- package/dist-server/service/user/user.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/account-lock-rule.d.ts +79 -0
- package/dist-server/utils/account-lock-rule.js +114 -0
- package/dist-server/utils/account-lock-rule.js.map +1 -0
- package/dist-server/utils/lock-duration.d.ts +3 -0
- package/dist-server/utils/lock-duration.js +36 -0
- package/dist-server/utils/lock-duration.js.map +1 -0
- package/package.json +4 -4
- package/tests/account-lock-db.test.ts +216 -0
- package/tests/account-lock-rule.test.ts +141 -0
- package/tests/compiled.ts +41 -0
- package/tests/has-privilege-db.test.ts +198 -0
- package/tests/lock-recovery.test.ts +204 -0
- package/tests/password-hashing.test.ts +131 -0
- package/tests/password-persistence.test.ts +147 -0
- package/tests/permission-gate.test.ts +226 -0
- package/tests/privilege-directive.test.ts +270 -0
- package/tests/role-mutation-db.test.ts +263 -0
- package/tests/role-privileges-db.test.ts +128 -0
- package/tests/signin-policy.test.ts +200 -0
- package/tests/token-issuance.test.ts +240 -0
- package/translations/en.json +161 -1
- package/translations/ko.json +161 -1
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 역할 만들기·고치기·지우기 — **실패했을 때 무엇을 말하나**를 실 DB 로 태운다.
|
|
3
|
+
*
|
|
4
|
+
* 이 셋은 권한 편집 화면이 서는 자리인데, 세 갈래의 실패가 전부 잘못된 말로 나가고 있었다.
|
|
5
|
+
*
|
|
6
|
+
* ① 부모 도메인의 역할을 고치려 하면 `role.name` 에서 TypeError → 500. 화면은 이유를 모른다.
|
|
7
|
+
* 목록은 상속으로 부모 역할까지 보여 주므로(`inherited: Include`) **실제로 자주 닿는다.**
|
|
8
|
+
* ② `privileges` 없는 patch(이름만 고치기)도 `patch.privileges.map` 에서 500.
|
|
9
|
+
* ③ `deleteRole` 의 catch 가 무엇이 났든 「사용 중이라 지울 수 없다」로 401 을 던졌다 —
|
|
10
|
+
* 못 찾은 것도 그렇게 말했다. 관리자는 있지도 않은 사용자를 떼러 갔다.
|
|
11
|
+
*
|
|
12
|
+
* 그리고 `deleteRoles`(복수)는 **도메인을 아예 안 봤다** — id 만 알면 남의 도메인 역할이 지워졌다.
|
|
13
|
+
*
|
|
14
|
+
* 지시자는 여기서 안 돈다(스키마 층이다). 게이트는 `privilege-directive.test.ts` 가 본다.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
18
|
+
import { authDataSource } from './db'
|
|
19
|
+
import { loadCompiled } from './compiled'
|
|
20
|
+
|
|
21
|
+
const { Role } = loadCompiled('service/role/role')
|
|
22
|
+
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
23
|
+
const { User } = loadCompiled('service/user/user')
|
|
24
|
+
const { RoleMutation } = loadCompiled('service/role/role-mutation')
|
|
25
|
+
|
|
26
|
+
const mutation = new RoleMutation()
|
|
27
|
+
|
|
28
|
+
let parent: any
|
|
29
|
+
let child: any
|
|
30
|
+
let stranger: any
|
|
31
|
+
let actor: any
|
|
32
|
+
let boardQuery: any
|
|
33
|
+
let boardMutation: any
|
|
34
|
+
|
|
35
|
+
async function loadDomain(id: string) {
|
|
36
|
+
return await getRepository(Domain).findOneBy({ id })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 리졸버가 실제로 쓰는 것만 담는다 — `tx`·`domain`·`user`·`t`·`throw`. */
|
|
40
|
+
function context(domain: any) {
|
|
41
|
+
return {
|
|
42
|
+
state: { domain, user: actor, tx: authDataSource().manager },
|
|
43
|
+
t: (key: string, params: any = {}) => `${key}${params.x ? `:${params.x}` : ''}`,
|
|
44
|
+
throw: (status: number, message: string) => {
|
|
45
|
+
throw new Error(`${status} ${message}`)
|
|
46
|
+
}
|
|
47
|
+
} as any
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
await openAuthDatabase()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
await closeAuthDatabase()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
await resetAuthDatabase()
|
|
60
|
+
|
|
61
|
+
const domainRepository = getRepository(Domain)
|
|
62
|
+
const savedParent = await domainRepository.save({ name: 'HQ', subdomain: 'hq' })
|
|
63
|
+
const savedChild = await domainRepository.save({ name: 'Plant A', subdomain: 'plant-a', parent: savedParent })
|
|
64
|
+
const savedStranger = await domainRepository.save({ name: 'Other', subdomain: 'other' })
|
|
65
|
+
|
|
66
|
+
parent = await loadDomain(savedParent.id)
|
|
67
|
+
child = await loadDomain(savedChild.id)
|
|
68
|
+
stranger = await loadDomain(savedStranger.id)
|
|
69
|
+
|
|
70
|
+
const privilegeRepository = getRepository(Privilege)
|
|
71
|
+
boardQuery = await privilegeRepository.save({ name: 'query', category: 'board' })
|
|
72
|
+
boardMutation = await privilegeRepository.save({ name: 'mutation', category: 'board' })
|
|
73
|
+
|
|
74
|
+
const salt = User.generateSalt()
|
|
75
|
+
actor = await getRepository(User).save({
|
|
76
|
+
username: 'admin',
|
|
77
|
+
email: 'admin@example.com',
|
|
78
|
+
name: 'Admin',
|
|
79
|
+
salt,
|
|
80
|
+
status: 'activated',
|
|
81
|
+
domains: [child]
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
async function makeRole(name: string, domain: any, privileges: any[] = [], users: any[] = []) {
|
|
86
|
+
return await getRepository(Role).save({ name, domain, privileges, users })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('고치기', () => {
|
|
90
|
+
it('권한을 갈아 끼운다', async () => {
|
|
91
|
+
const role = await makeRole('viewer', child, [boardQuery])
|
|
92
|
+
|
|
93
|
+
await mutation.updateRole(role.id, { privileges: [{ id: boardMutation.id }] } as any, context(child))
|
|
94
|
+
|
|
95
|
+
const saved = await getRepository(Role).findOne({ where: { id: role.id }, relations: ['privileges'] })
|
|
96
|
+
expect(saved.privileges.map((p: any) => p.id)).toEqual([boardMutation.id])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('`privileges` 를 안 보내면 권한을 건드리지 않는다 — 이름만 고치는 호출이 있다', async () => {
|
|
100
|
+
/* 예전에는 이 호출이 `patch.privileges.map` 에서 TypeError 로 500 이었다. */
|
|
101
|
+
const role = await makeRole('viewer', child, [boardQuery])
|
|
102
|
+
|
|
103
|
+
await mutation.updateRole(role.id, { name: 'reader' } as any, context(child))
|
|
104
|
+
|
|
105
|
+
const saved = await getRepository(Role).findOne({ where: { id: role.id }, relations: ['privileges'] })
|
|
106
|
+
expect(saved.name).toBe('reader')
|
|
107
|
+
expect(saved.privileges.map((p: any) => p.id)).toEqual([boardQuery.id])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('부모 도메인의 역할은 어디서 고치는지 말하고 거절한다', async () => {
|
|
111
|
+
/*
|
|
112
|
+
* 목록에 보이는 것과 고칠 수 있는 것이 다르다. 예전에는 여기서 TypeError 가 나 500 이었고
|
|
113
|
+
* 화면은 「알 수 없는 오류」만 보여 줬다.
|
|
114
|
+
*/
|
|
115
|
+
const role = await makeRole('shared', parent, [boardQuery])
|
|
116
|
+
|
|
117
|
+
await expect(mutation.updateRole(role.id, { name: 'mine' } as any, context(child))).rejects.toThrow(
|
|
118
|
+
/parent domain/
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('같은 이름이 이미 있으면 거절한다', async () => {
|
|
123
|
+
await makeRole('viewer', child)
|
|
124
|
+
const role = await makeRole('editor', child)
|
|
125
|
+
|
|
126
|
+
await expect(mutation.updateRole(role.id, { name: 'viewer' } as any, context(child))).rejects.toThrow(
|
|
127
|
+
'role name is duplicated'
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('지우기 — 하나', () => {
|
|
133
|
+
it('아무도 안 가진 역할은 지운다', async () => {
|
|
134
|
+
const role = await makeRole('unused', child, [boardQuery])
|
|
135
|
+
|
|
136
|
+
expect(await mutation.deleteRole(role.id, context(child))).toBe(true)
|
|
137
|
+
expect(await getRepository(Role).findOneBy({ id: role.id })).toBeNull()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('가진 사람이 있으면 그 이유로 거절한다', async () => {
|
|
141
|
+
const role = await makeRole('in-use', child, [boardQuery], [actor])
|
|
142
|
+
|
|
143
|
+
await expect(mutation.deleteRole(role.id, context(child))).rejects.toThrow('currently in use')
|
|
144
|
+
expect(await getRepository(Role).findOneBy({ id: role.id })).not.toBeNull()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('부모 도메인의 역할을 「사용 중」이라고 말하지 않는다', async () => {
|
|
148
|
+
/*
|
|
149
|
+
* 이것이 옛 구현의 가장 나쁜 점이었다 — 못 찾은 것을 「사용 중」이라고 말했으므로 관리자는
|
|
150
|
+
* 있지도 않은 사용자를 찾아 떼려 했다.
|
|
151
|
+
*/
|
|
152
|
+
const role = await makeRole('shared', parent)
|
|
153
|
+
|
|
154
|
+
await expect(mutation.deleteRole(role.id, context(child))).rejects.toThrow(/parent domain/)
|
|
155
|
+
await expect(mutation.deleteRole(role.id, context(child))).rejects.not.toThrow(/in use/)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('지우기 — 여럿', () => {
|
|
160
|
+
it('남의 도메인 역할은 id 를 알아도 안 지운다', async () => {
|
|
161
|
+
const mine = await makeRole('mine', child)
|
|
162
|
+
const theirs = await makeRole('theirs', stranger)
|
|
163
|
+
|
|
164
|
+
await mutation.deleteRoles([mine.id, theirs.id], context(child))
|
|
165
|
+
|
|
166
|
+
expect(await getRepository(Role).findOneBy({ id: mine.id })).toBeNull()
|
|
167
|
+
expect(await getRepository(Role).findOneBy({ id: theirs.id })).not.toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('사용 중인 역할은 남긴다 — 하나 지우기와 같은 규칙이다', async () => {
|
|
171
|
+
const unused = await makeRole('unused', child)
|
|
172
|
+
const inUse = await makeRole('in-use', child, [], [actor])
|
|
173
|
+
|
|
174
|
+
await mutation.deleteRoles([unused.id, inUse.id], context(child))
|
|
175
|
+
|
|
176
|
+
expect(await getRepository(Role).findOneBy({ id: unused.id })).toBeNull()
|
|
177
|
+
expect(await getRepository(Role).findOneBy({ id: inUse.id })).not.toBeNull()
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 격자에서 여러 줄을 고쳐 한 번에 보내는 길.
|
|
183
|
+
*
|
|
184
|
+
* 목록 화면이 `dirtyRecords` 를 `cuFlag` 와 함께 보낸다. 여기서 지키는 것은 하나가 제일 크다 —
|
|
185
|
+
* **`privileges` 를 안 보낸 patch 가 권한을 비우지 않는 것.** 격자는 이름만 고치는데 그것이
|
|
186
|
+
* 권한을 지우면 그 역할이 아무것도 안 여는 역할이 된다.
|
|
187
|
+
*/
|
|
188
|
+
describe('격자에서 여럿 저장', () => {
|
|
189
|
+
it('`+` 는 만들고 `M` 은 고친다', async () => {
|
|
190
|
+
const existing = await makeRole('viewer', child, [boardQuery])
|
|
191
|
+
|
|
192
|
+
await mutation.updateMultipleRole(
|
|
193
|
+
[
|
|
194
|
+
{ cuFlag: '+', name: 'operator', description: '현장' },
|
|
195
|
+
{ cuFlag: 'M', id: existing.id, name: 'reader' }
|
|
196
|
+
] as any,
|
|
197
|
+
context(child)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
const roles = await getRepository(Role).find({ where: { domain: { id: child.id } } })
|
|
201
|
+
expect(roles.map((r: any) => r.name).sort()).toEqual(['operator', 'reader'])
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('이름만 고쳐도 권한은 그대로다 — 격자는 권한을 안 보낸다', async () => {
|
|
205
|
+
/*
|
|
206
|
+
* 이것이 이 자리의 가장 조용한 결함이 될 수 있었다. 관리자가 격자에서 오타를 고쳤는데
|
|
207
|
+
* 그 역할이 아무것도 안 여는 역할이 되면, 그 역할을 가진 사람들이 다음 로그인에 막힌다.
|
|
208
|
+
*/
|
|
209
|
+
const role = await makeRole('viewer', child, [boardQuery, boardMutation])
|
|
210
|
+
|
|
211
|
+
await mutation.updateMultipleRole([{ cuFlag: 'M', id: role.id, name: 'reader' }] as any, context(child))
|
|
212
|
+
|
|
213
|
+
const saved = await getRepository(Role).findOne({ where: { id: role.id }, relations: ['privileges'] })
|
|
214
|
+
expect(saved.privileges.map((p: any) => p.id).sort()).toEqual([boardMutation.id, boardQuery.id].sort())
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('`cuFlag` 가 없는 줄은 아무것도 하지 않는다 — 짐작해서 만들지 않는다', async () => {
|
|
218
|
+
await mutation.updateMultipleRole([{ name: 'ghost' }] as any, context(child))
|
|
219
|
+
|
|
220
|
+
expect(await getRepository(Role).findOneBy({ name: 'ghost' })).toBeNull()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('부모 도메인의 역할을 고치려 하면 어디서 고치는지 말한다', async () => {
|
|
224
|
+
const role = await makeRole('shared', parent)
|
|
225
|
+
|
|
226
|
+
await expect(
|
|
227
|
+
mutation.updateMultipleRole([{ cuFlag: 'M', id: role.id, name: 'mine' }] as any, context(child))
|
|
228
|
+
).rejects.toThrow(/parent domain/)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('이름 없이 만들려 하면 거절한다', async () => {
|
|
232
|
+
await expect(mutation.updateMultipleRole([{ cuFlag: '+', name: ' ' }] as any, context(child))).rejects.toThrow()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('같은 이름으로 만들려 하면 거절한다', async () => {
|
|
236
|
+
await makeRole('operator', child)
|
|
237
|
+
|
|
238
|
+
await expect(
|
|
239
|
+
mutation.updateMultipleRole([{ cuFlag: '+', name: 'operator' }] as any, context(child))
|
|
240
|
+
).rejects.toThrow('role name is duplicated')
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
describe('만들기', () => {
|
|
245
|
+
it('내 도메인에 만들고 고른 권한을 붙인다', async () => {
|
|
246
|
+
const created = await mutation.createRole(
|
|
247
|
+
{ name: 'operator', privileges: [{ id: boardQuery.id }] } as any,
|
|
248
|
+
context(child)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
const saved = await getRepository(Role).findOne({ where: { id: created.id }, relations: ['privileges', 'domain'] })
|
|
252
|
+
expect(saved.domain.id).toBe(child.id)
|
|
253
|
+
expect(saved.privileges.map((p: any) => p.id)).toEqual([boardQuery.id])
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('같은 도메인에 같은 이름은 안 만든다', async () => {
|
|
257
|
+
await makeRole('operator', child)
|
|
258
|
+
|
|
259
|
+
await expect(mutation.createRole({ name: 'operator' } as any, context(child))).rejects.toThrow(
|
|
260
|
+
'role name is duplicated'
|
|
261
|
+
)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rolePrivileges` — 권한 편집 화면이 드는 **한 번의 질의**를 실 DB 로 태운다.
|
|
3
|
+
*
|
|
4
|
+
* 이 질의는 두 가지를 함께 답한다: 「무엇이 있나」와 「이 역할이 무엇을 갖나」. 화면이 그것을
|
|
5
|
+
* 질의 둘로 받으면 짝짓기를 화면이 해야 하는데, 옛 화면은 그 짝을 역할 **이름**으로 맞췄고
|
|
6
|
+
* 부모 도메인에 같은 이름의 역할이 있으면 남의 권한을 그렸다.
|
|
7
|
+
*
|
|
8
|
+
* 그리고 이 질의는 얼마 전까지 **원시 SQL** 이었다. 셋을 동시에 잃고 있었다 —
|
|
9
|
+
* `roleId` 문자열 이어 붙이기, `THEN true`(MSSQL 에 없는 리터럴), 도메인 무시. 아래 시험이
|
|
10
|
+
* 그 셋 중 되돌아올 수 있는 둘을 붙잡는다.
|
|
11
|
+
*
|
|
12
|
+
* ⚠ sqlite 로는 방언 문제를 **못 본다**(`tests/db.ts`). 그것은 원시 SQL 을 안 쓰는 것으로만
|
|
13
|
+
* 막힌다. 그래서 이 시험은 「관계로 읽는다」는 결과를 지키고, 방언은 코드에서 지운다.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
17
|
+
import { loadCompiled } from './compiled'
|
|
18
|
+
|
|
19
|
+
const { Role } = loadCompiled('service/role/role')
|
|
20
|
+
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
21
|
+
const { RoleQuery } = loadCompiled('service/role/role-query')
|
|
22
|
+
|
|
23
|
+
const query = new RoleQuery()
|
|
24
|
+
|
|
25
|
+
let parent: any
|
|
26
|
+
let child: any
|
|
27
|
+
let stranger: any
|
|
28
|
+
let boardQuery: any
|
|
29
|
+
let boardMutation: any
|
|
30
|
+
|
|
31
|
+
/** `parentId` 는 `@RelationId` 라 저장소로 읽은 도메인에만 실린다. */
|
|
32
|
+
async function loadDomain(id: string) {
|
|
33
|
+
return await getRepository(Domain).findOneBy({ id })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 지시자가 없으므로 리졸버 본문을 직접 부른다 — 게이트는 `privilege-directive.test.ts` 가 본다. */
|
|
37
|
+
function ask(roleId: string, domain: any) {
|
|
38
|
+
return query.rolePrivileges(roleId, { state: { domain } } as any)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
await openAuthDatabase()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
await closeAuthDatabase()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
await resetAuthDatabase()
|
|
51
|
+
|
|
52
|
+
const domainRepository = getRepository(Domain)
|
|
53
|
+
|
|
54
|
+
const savedParent = await domainRepository.save({ name: 'HQ', subdomain: 'hq' })
|
|
55
|
+
const savedChild = await domainRepository.save({ name: 'Plant A', subdomain: 'plant-a', parent: savedParent })
|
|
56
|
+
const savedStranger = await domainRepository.save({ name: 'Other', subdomain: 'other' })
|
|
57
|
+
|
|
58
|
+
parent = await loadDomain(savedParent.id)
|
|
59
|
+
child = await loadDomain(savedChild.id)
|
|
60
|
+
stranger = await loadDomain(savedStranger.id)
|
|
61
|
+
|
|
62
|
+
const privilegeRepository = getRepository(Privilege)
|
|
63
|
+
boardQuery = await privilegeRepository.save({ name: 'query', category: 'board' })
|
|
64
|
+
boardMutation = await privilegeRepository.save({ name: 'mutation', category: 'board' })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('가진 것과 안 가진 것', () => {
|
|
68
|
+
it('전체 권한을 돌려주고, 가진 것에만 표시한다', async () => {
|
|
69
|
+
const role = await getRepository(Role).save({ name: 'viewer', domain: child, privileges: [boardQuery] })
|
|
70
|
+
|
|
71
|
+
const rows = await ask(role.id, child)
|
|
72
|
+
|
|
73
|
+
expect(rows).toHaveLength(2)
|
|
74
|
+
expect(rows.find(row => row.id === boardQuery.id).assigned).toBe(true)
|
|
75
|
+
expect(rows.find(row => row.id === boardMutation.id).assigned).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('아무것도 없는 역할에도 목록을 준다 — 편집을 시작할 수 있어야 한다', async () => {
|
|
79
|
+
const role = await getRepository(Role).save({ name: 'blank', domain: child, privileges: [] })
|
|
80
|
+
|
|
81
|
+
const rows = await ask(role.id, child)
|
|
82
|
+
|
|
83
|
+
expect(rows).toHaveLength(2)
|
|
84
|
+
expect(rows.every(row => row.assigned === false)).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('도메인 범위', () => {
|
|
89
|
+
it('부모 도메인의 역할은 볼 수 있다 — 상속으로 목록에 나오는 것과 같은 범위다', async () => {
|
|
90
|
+
const role = await getRepository(Role).save({ name: 'shared', domain: parent, privileges: [boardMutation] })
|
|
91
|
+
|
|
92
|
+
const rows = await ask(role.id, child)
|
|
93
|
+
|
|
94
|
+
expect(rows.find(row => row.id === boardMutation.id).assigned).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('남의 도메인 역할은 못 본다 — id 를 알아도 답하지 않는다', async () => {
|
|
98
|
+
const role = await getRepository(Role).save({ name: 'theirs', domain: stranger, privileges: [boardMutation] })
|
|
99
|
+
|
|
100
|
+
expect(await ask(role.id, child)).toEqual([])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('자식 도메인의 역할도 못 본다 — 상속은 위로 한 단이고 아래로는 없다', async () => {
|
|
104
|
+
const role = await getRepository(Role).save({ name: 'local', domain: child, privileges: [boardQuery] })
|
|
105
|
+
|
|
106
|
+
expect(await ask(role.id, parent)).toEqual([])
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('없는 역할', () => {
|
|
111
|
+
it('빈 목록을 준다 — 「전체 권한을 아무것도 안 가진 역할」로 보이지 않는다', async () => {
|
|
112
|
+
/*
|
|
113
|
+
* 이 구별이 화면에서 중요하다. 전체 목록을 주면서 `assigned` 를 전부 거짓으로 두면
|
|
114
|
+
* 화면은 「권한이 하나도 없는 역할」을 그리고, 관리자가 저장하면 **실제로** 그렇게 된다.
|
|
115
|
+
*/
|
|
116
|
+
expect(await ask('00000000-0000-0000-0000-000000000000', child)).toEqual([])
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('부러지지 않는다', () => {
|
|
121
|
+
it('id 자리에 SQL 을 넣어도 던지지 않고 빈 목록을 준다', async () => {
|
|
122
|
+
/*
|
|
123
|
+
* 옛 구현은 이 문자열을 SQL 에 그대로 이어 붙였다. `user:query` 를 가진 사람이 닿는
|
|
124
|
+
* 자리이므로 그것으로 충분히 위험했다. 지금은 값으로만 쓰인다.
|
|
125
|
+
*/
|
|
126
|
+
expect(await ask("' OR 1=1 --", child)).toEqual([])
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `signin` 의 상태 정책 — 실 DB 로 잠금까지 몰아 본다.
|
|
3
|
+
*
|
|
4
|
+
* 이 경로는 플랫폼에서 사람이 들어오는 **유일한 문**인데 시험이 하나도 없었다. 정책이 코드에
|
|
5
|
+
* 흩어져 있고(실패 셈, 잠금, `status` 분기 넷), 순서가 결과를 바꾼다 — 그래서 읽기만으로는
|
|
6
|
+
* 「이 계정이 왜 못 들어오나」를 말할 수 없다.
|
|
7
|
+
*
|
|
8
|
+
* ── 실제로 부르는 모양을 그대로 쓴다 ────────────────────────────────────────
|
|
9
|
+
* `signinMiddleware:20` 은 `signin({ username, password })` 를 **context 없이** 부른다.
|
|
10
|
+
* 그래서 시험도 context 를 넘기지 않는다 — 넘기면 시험이 화면보다 나은 조건에서 도는 것이 된다.
|
|
11
|
+
* 그 차이가 아래 「잠금 해제 메일」 케이스의 주제다.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
15
|
+
import { loadCompiled } from './compiled'
|
|
16
|
+
|
|
17
|
+
const { User } = loadCompiled('service/user/user')
|
|
18
|
+
const { signin } = loadCompiled('controllers/signin')
|
|
19
|
+
const { VerificationToken } = loadCompiled('service/verification-token/verification-token')
|
|
20
|
+
/* 오류 코드는 문자열이고 표기가 제각각이다(`'user not found'` · `'password-not-matched'`).
|
|
21
|
+
시험이 그 표기를 베껴 쓰면 코드를 고칠 때 시험만 조용히 틀린다 — 상수를 그대로 든다. */
|
|
22
|
+
const CODES = loadCompiled('constants/error-code')
|
|
23
|
+
|
|
24
|
+
const PASSWORD = 'Passw0rd!'
|
|
25
|
+
const WRONG = 'Wr0ngPass!'
|
|
26
|
+
|
|
27
|
+
let domain: any
|
|
28
|
+
|
|
29
|
+
async function seedUser(overrides: any = {}) {
|
|
30
|
+
const salt = User.generateSalt()
|
|
31
|
+
|
|
32
|
+
return await getRepository(User).save({
|
|
33
|
+
username: 'kim',
|
|
34
|
+
email: 'kim@example.com',
|
|
35
|
+
name: 'Kim',
|
|
36
|
+
salt,
|
|
37
|
+
password: User.encode(PASSWORD, salt),
|
|
38
|
+
status: 'activated',
|
|
39
|
+
domains: [domain],
|
|
40
|
+
...overrides
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 실 경로와 같게 — context 없이 부른다. */
|
|
45
|
+
const attempt = (password: string, username = 'kim') => signin({ username, password })
|
|
46
|
+
|
|
47
|
+
async function reload(id: string) {
|
|
48
|
+
return await getRepository(User).findOneBy({ id })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
await openAuthDatabase()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
await resetAuthDatabase()
|
|
57
|
+
domain = await getRepository(Domain).save({ name: 'plant-a', subdomain: 'plant-a' })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
await closeAuthDatabase()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('들어가기', () => {
|
|
65
|
+
it('맞는 비밀번호로 토큰과 소속 도메인을 받는다', async () => {
|
|
66
|
+
const user = await seedUser()
|
|
67
|
+
|
|
68
|
+
const result = await attempt(PASSWORD)
|
|
69
|
+
|
|
70
|
+
expect(result.token).toBeTruthy()
|
|
71
|
+
expect(result.user.id).toBe(user.id)
|
|
72
|
+
expect(result.domains.map((d: any) => d.subdomain)).toEqual(['plant-a'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('username 이 아니라 email 로도 들어간다', async () => {
|
|
76
|
+
await seedUser()
|
|
77
|
+
|
|
78
|
+
const result = await attempt(PASSWORD, 'kim@example.com')
|
|
79
|
+
|
|
80
|
+
expect(result.token).toBeTruthy()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('실패 셈과 잠금', () => {
|
|
85
|
+
it('틀릴 때마다 셈이 하나 오른다', async () => {
|
|
86
|
+
const user = await seedUser()
|
|
87
|
+
|
|
88
|
+
for (const expected of [1, 2, 3, 4]) {
|
|
89
|
+
await expect(attempt(WRONG)).rejects.toThrow()
|
|
90
|
+
expect((await reload(user.id)).failCount).toBe(expected)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* 4회까지는 아직 열려 있다. */
|
|
94
|
+
expect((await reload(user.id)).status).toBe('activated')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('다섯 번째에 잠긴다', async () => {
|
|
98
|
+
const user = await seedUser()
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < 5; i++) {
|
|
101
|
+
await expect(attempt(WRONG)).rejects.toThrow()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const locked = await reload(user.id)
|
|
105
|
+
expect(locked.status).toBe('locked')
|
|
106
|
+
expect(locked.failCount).toBe(5)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('잠긴 뒤에는 **맞는 비밀번호도** 거절한다', async () => {
|
|
110
|
+
/* 잠금 검사가 비밀번호 대조보다 먼저 온다(signin.ts:39). 그래서 남이 남의 계정을
|
|
111
|
+
잠글 수 있고, 잠긴 뒤 본인도 못 들어온다 — 지금 정책이 그렇다. */
|
|
112
|
+
const user = await seedUser({ status: 'locked' })
|
|
113
|
+
|
|
114
|
+
await expect(attempt(PASSWORD)).rejects.toThrow()
|
|
115
|
+
expect((await reload(user.id)).status).toBe('locked')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('성공하면 셈이 0 으로 돌아간다', async () => {
|
|
119
|
+
const user = await seedUser()
|
|
120
|
+
|
|
121
|
+
await expect(attempt(WRONG)).rejects.toThrow()
|
|
122
|
+
await expect(attempt(WRONG)).rejects.toThrow()
|
|
123
|
+
expect((await reload(user.id)).failCount).toBe(2)
|
|
124
|
+
|
|
125
|
+
await attempt(PASSWORD)
|
|
126
|
+
expect((await reload(user.id)).failCount).toBe(0)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('상태 분기', () => {
|
|
131
|
+
it('삭제된 계정은 비밀번호를 보기도 전에 거절한다', async () => {
|
|
132
|
+
const user = await seedUser({ status: 'deleted' })
|
|
133
|
+
|
|
134
|
+
await expect(attempt(PASSWORD)).rejects.toThrow()
|
|
135
|
+
/* 셈이 오르지 않는다 — 비밀번호 대조 앞에서 끊긴다. */
|
|
136
|
+
expect((await reload(user.id)).failCount).toBe(0)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('미활성 계정은 비밀번호를 **맞춘 뒤에** 거절한다', async () => {
|
|
140
|
+
/* 순서가 뜻을 만든다: 틀리면 「비밀번호가 틀렸다」, 맞히면 「활성화되지 않았다」.
|
|
141
|
+
즉 미활성 계정에 대해 비밀번호 맞음/틀림이 응답으로 갈린다. */
|
|
142
|
+
const user = await seedUser({ status: 'inactive' })
|
|
143
|
+
|
|
144
|
+
await expect(attempt(WRONG)).rejects.toMatchObject({ errorCode: CODES.PASSWORD_NOT_MATCHED })
|
|
145
|
+
await expect(attempt(PASSWORD)).rejects.toMatchObject({ errorCode: CODES.USER_NOT_ACTIVATED })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('없는 계정과 틀린 비밀번호가 다른 오류 코드로 갈린다', async () => {
|
|
149
|
+
await seedUser()
|
|
150
|
+
|
|
151
|
+
await expect(attempt(PASSWORD, 'nobody')).rejects.toMatchObject({ errorCode: CODES.USER_NOT_FOUND })
|
|
152
|
+
await expect(attempt(WRONG)).rejects.toMatchObject({ errorCode: CODES.PASSWORD_NOT_MATCHED })
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('실패 응답에 실려 나가는 것', () => {
|
|
157
|
+
it('인증되지 않은 실패가 email 과 남은 시도 횟수를 함께 싣는다', async () => {
|
|
158
|
+
/* `detail` 이 화면까지 가는 경로가 있다. 아무 자격 없이 세 가지를 알 수 있다 —
|
|
159
|
+
그 계정이 있다는 것, 그 계정의 email, 그리고 잠기기까지 몇 번 남았는지. */
|
|
160
|
+
await seedUser()
|
|
161
|
+
|
|
162
|
+
await expect(attempt(WRONG)).rejects.toMatchObject({
|
|
163
|
+
detail: { username: 'kim', email: 'kim@example.com', failCount: 1 }
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('잠금 해제 메일', () => {
|
|
169
|
+
it('잠기면 해제 토큰이 만들어진다', async () => {
|
|
170
|
+
/*
|
|
171
|
+
* ⚠ **부르는 쪽이 `await` 하지 않는다** — `signin.ts:40,58` 이 `sendUnlockUserEmail(...)` 을
|
|
172
|
+
* 띄워 놓고 곧바로 던진다. 그래서 시험이 바로 읽으면 **0건이 나온다.** 그 0건은 제품의
|
|
173
|
+
* 사실이 아니라 아직 도착하지 않은 쓰기다(처음에 그것을 「메일이 안 간다」로 읽었다).
|
|
174
|
+
*
|
|
175
|
+
* 여기서는 그 성질 자체를 고정한다: 즉시 읽으면 없고, 기다리면 있다. 언젠가 그 호출에
|
|
176
|
+
* `await` 가 붙으면 앞의 기대가 깨져서 알려 줄 것이다.
|
|
177
|
+
*
|
|
178
|
+
* 메일 발송 자체는 이 하네스로 확인하지 않는다 — `sendEmail` 이 `config.get('email')` 을
|
|
179
|
+
* 읽는데 시험 환경에는 그 설정이 없다(`emailInfo.sender` 에서 던진다). 발송은 운영 설정의
|
|
180
|
+
* 일이고, 실제로 도착하고 있음을 사용자가 확인했다.
|
|
181
|
+
*/
|
|
182
|
+
const user = await seedUser()
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < 5; i++) {
|
|
185
|
+
await expect(attempt(WRONG)).rejects.toThrow()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
expect((await reload(user.id)).status).toBe('locked')
|
|
189
|
+
|
|
190
|
+
/* 던진 직후에는 아직 없다 — 부르는 쪽이 기다리지 않기 때문이다. */
|
|
191
|
+
expect(await getRepository(VerificationToken).find()).toHaveLength(0)
|
|
192
|
+
|
|
193
|
+
await new Promise(resolve => setTimeout(resolve, 300))
|
|
194
|
+
|
|
195
|
+
const tokens = await getRepository(VerificationToken).find()
|
|
196
|
+
expect(tokens).toHaveLength(1)
|
|
197
|
+
expect(tokens[0].type).toBe('unlock')
|
|
198
|
+
expect(tokens[0].userId).toBe(user.id)
|
|
199
|
+
})
|
|
200
|
+
})
|