ldap-authentication 3.3.1 → 3.3.3
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/README.md +3 -3
- package/example/index.js +2 -2
- package/index.d.ts +1 -1
- package/index.js +79 -104
- package/package.json +2 -2
- package/test/string.spec.js +12 -53
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Make authentication with an LDAP server easy.
|
|
|
10
10
|
|
|
11
11
|
## Description
|
|
12
12
|
|
|
13
|
-
This library use `
|
|
13
|
+
This library use `ldapts` as the underneath library. It has three modes of authentications:
|
|
14
14
|
|
|
15
15
|
1. **Admin authenticate mode**. If an admin user is provided, the library will login (ldap bind) with the admin user,
|
|
16
16
|
then search for the user to be authenticated, get its DN (distinguish name), then use
|
|
@@ -149,7 +149,7 @@ auth()
|
|
|
149
149
|
|
|
150
150
|
## Parameters
|
|
151
151
|
|
|
152
|
-
- `ldapOpts`: This is passed to `
|
|
152
|
+
- `ldapOpts`: This is passed to `ldapts` client directly
|
|
153
153
|
- `url`: url of the ldap server. Example: `ldap://ldap.forumsys.com`
|
|
154
154
|
- `tlsOptions`: options to pass to node tls. Example: `{ rejectUnauthorized: false }`
|
|
155
155
|
- `connectTimeout`: Int. Default: `5000`. Connect timeout in ms
|
|
@@ -210,7 +210,7 @@ export async function verifyLogin(email: string, password: string) {
|
|
|
210
210
|
const profilePhoto = ldapUser['thumbnailPhoto;binary'];
|
|
211
211
|
|
|
212
212
|
/* using the image
|
|
213
|
-
|
|
213
|
+
<img src={`data:image/*;base64,${profilePhoto}`} />
|
|
214
214
|
*/
|
|
215
215
|
return { user: ldapUser };
|
|
216
216
|
}
|
package/example/index.js
CHANGED
|
@@ -4,7 +4,7 @@ async function auth() {
|
|
|
4
4
|
// auth with admin
|
|
5
5
|
let options = {
|
|
6
6
|
ldapOpts: {
|
|
7
|
-
url: 'ldap://
|
|
7
|
+
url: 'ldap://localhost:1389',
|
|
8
8
|
// tlsOptions: { rejectUnauthorized: false }
|
|
9
9
|
},
|
|
10
10
|
adminDn: 'cn=read-only-admin,dc=example,dc=com',
|
|
@@ -55,4 +55,4 @@ async function auth() {
|
|
|
55
55
|
console.log(`user = ${JSON.stringify(user, null, 2)}`)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
auth()
|
|
58
|
+
auth().then()
|
package/index.d.ts
CHANGED
package/index.js
CHANGED
|
@@ -2,97 +2,42 @@ const assert = require('assert')
|
|
|
2
2
|
const ldapts = require('ldapts')
|
|
3
3
|
// escape the , in CN in DN
|
|
4
4
|
function _ldapEscapeDN(s) {
|
|
5
|
-
let ret =
|
|
6
|
-
let comaPositions = []
|
|
7
|
-
let done = false
|
|
8
|
-
let countEq = 0
|
|
5
|
+
let ret = ''
|
|
6
|
+
let comaPositions = []
|
|
7
|
+
let done = false
|
|
8
|
+
let countEq = 0
|
|
9
9
|
for (let i = 0; !done && i < s.length; i++) {
|
|
10
10
|
switch (s[i]) {
|
|
11
|
-
case
|
|
11
|
+
case '\\':
|
|
12
12
|
// user already escapped, continue
|
|
13
|
-
i
|
|
14
|
-
break
|
|
15
|
-
case
|
|
13
|
+
i++
|
|
14
|
+
break
|
|
15
|
+
case ',':
|
|
16
16
|
if (countEq == 1) {
|
|
17
|
-
comaPositions.push(i)
|
|
17
|
+
comaPositions.push(i)
|
|
18
18
|
}
|
|
19
|
-
break
|
|
20
|
-
case
|
|
21
|
-
countEq
|
|
19
|
+
break
|
|
20
|
+
case '=':
|
|
21
|
+
countEq++
|
|
22
22
|
if (countEq == 2) {
|
|
23
|
-
done = true
|
|
23
|
+
done = true
|
|
24
24
|
}
|
|
25
|
-
break
|
|
25
|
+
break
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
if (done) {
|
|
29
|
-
comaPositions.pop()
|
|
29
|
+
comaPositions.pop()
|
|
30
30
|
}
|
|
31
|
-
let lastIndex = 0
|
|
31
|
+
let lastIndex = 0
|
|
32
32
|
for (let i of comaPositions) {
|
|
33
|
-
ret += s.substring(lastIndex, i)
|
|
34
|
-
ret +=
|
|
35
|
-
lastIndex = i + 1
|
|
33
|
+
ret += s.substring(lastIndex, i)
|
|
34
|
+
ret += '\\,'
|
|
35
|
+
lastIndex = i + 1
|
|
36
36
|
}
|
|
37
|
-
ret += s.substring(lastIndex)
|
|
38
|
-
return ret
|
|
37
|
+
ret += s.substring(lastIndex)
|
|
38
|
+
return ret
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// convert an escaped utf8 string returned from ldapjs
|
|
42
|
-
function _isHex(c) {
|
|
43
|
-
return (
|
|
44
|
-
(c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
|
45
|
-
)
|
|
46
|
-
}
|
|
47
|
-
function _parseEscapedHexToUtf8(s) {
|
|
48
|
-
// convert 'cn=\\e7\\a0\\94\\e5\\8f\\91A\\e9\\83\\a8,ou=users,dc=example,dc=com'
|
|
49
|
-
// to 'cn=研发A部,ou=users,dc=example,dc=com'
|
|
50
|
-
let ret = Buffer.alloc(0)
|
|
51
|
-
let len = s.length
|
|
52
|
-
for (let i = 0; i < len; i++) {
|
|
53
|
-
let c = s[i]
|
|
54
|
-
let item
|
|
55
|
-
if (c == '\\' && i < len - 2 && _isHex(s[i + 1]) && _isHex(s[i + 2])) {
|
|
56
|
-
item = Buffer.from(s.substring(i + 1, i + 3), 'hex')
|
|
57
|
-
i += 2
|
|
58
|
-
} else {
|
|
59
|
-
item = Buffer.from(c)
|
|
60
|
-
}
|
|
61
|
-
ret = Buffer.concat([ret, item])
|
|
62
|
-
}
|
|
63
|
-
return ret.toString()
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function _recursiveParseHexString(obj) {
|
|
67
|
-
if (Array.isArray(obj)) {
|
|
68
|
-
return obj.map((ele) => _recursiveParseHexString(ele))
|
|
69
|
-
}
|
|
70
|
-
if (typeof obj == 'string') {
|
|
71
|
-
return _parseEscapedHexToUtf8(obj)
|
|
72
|
-
}
|
|
73
|
-
if (typeof obj == 'object') {
|
|
74
|
-
for (let key in obj) {
|
|
75
|
-
obj[key] = _recursiveParseHexString(obj[key])
|
|
76
|
-
}
|
|
77
|
-
return obj
|
|
78
|
-
}
|
|
79
|
-
return obj
|
|
80
|
-
}
|
|
81
|
-
/*
|
|
82
|
-
// UPDATE: This function is not used in this version, because ldapts library already does the conversion
|
|
83
|
-
// convert a SearchResultEntry object in ldapjs 3.0
|
|
84
|
-
// to a user object to maintain backward compatibility
|
|
85
|
-
|
|
86
|
-
function _searchResultToUser(pojo) {
|
|
87
|
-
assert(pojo.type == 'SearchResultEntry')
|
|
88
|
-
let user = { dn: pojo.objectName }
|
|
89
|
-
pojo.attributes.forEach((attribute) => {
|
|
90
|
-
user[attribute.type] =
|
|
91
|
-
attribute.values.length == 1 ? attribute.values[0] : attribute.values
|
|
92
|
-
})
|
|
93
|
-
return _recursiveParseHexString(user)
|
|
94
|
-
}
|
|
95
|
-
*/
|
|
96
41
|
// bind and return the ldap client
|
|
97
42
|
async function _ldapBind(dn, password, starttls, ldapOpts) {
|
|
98
43
|
// TODO: check if ldapts expects escaped dn or not (possible double escaping problems?)
|
|
@@ -100,7 +45,7 @@ async function _ldapBind(dn, password, starttls, ldapOpts) {
|
|
|
100
45
|
ldapOpts.connectTimeout = ldapOpts.connectTimeout || 5000
|
|
101
46
|
let client = new ldapts.Client(ldapOpts)
|
|
102
47
|
|
|
103
|
-
if(starttls) {
|
|
48
|
+
if (starttls) {
|
|
104
49
|
await client.startTLS(ldapOpts.tlsOptions)
|
|
105
50
|
}
|
|
106
51
|
|
|
@@ -117,18 +62,17 @@ async function _searchUser(
|
|
|
117
62
|
username,
|
|
118
63
|
attributes = null
|
|
119
64
|
) {
|
|
120
|
-
|
|
121
65
|
let filter = new ldapts.EqualityFilter({
|
|
122
66
|
attribute: usernameAttribute,
|
|
123
67
|
value: username,
|
|
124
|
-
})
|
|
68
|
+
})
|
|
125
69
|
let searchOptions = {
|
|
126
70
|
filter: filter,
|
|
127
71
|
scope: 'sub',
|
|
128
72
|
attributes: attributes,
|
|
129
|
-
}
|
|
73
|
+
}
|
|
130
74
|
if (attributes) {
|
|
131
|
-
searchOptions.attributes = attributes
|
|
75
|
+
searchOptions.attributes = attributes
|
|
132
76
|
}
|
|
133
77
|
|
|
134
78
|
// TODO: we don't support reference yet
|
|
@@ -137,16 +81,24 @@ async function _searchUser(
|
|
|
137
81
|
// the server may return one or more SearchResultReference messages,
|
|
138
82
|
// each containing a reference to another set of servers for continuing the operation.
|
|
139
83
|
// referral.uris
|
|
140
|
-
const { searchEntries, searchReferences } = await ldapClient.search(
|
|
84
|
+
const { searchEntries, searchReferences } = await ldapClient.search(
|
|
85
|
+
searchBase,
|
|
86
|
+
searchOptions
|
|
87
|
+
)
|
|
141
88
|
|
|
142
|
-
let user
|
|
143
|
-
if(
|
|
144
|
-
|
|
89
|
+
let user
|
|
90
|
+
if (
|
|
91
|
+
!searchEntries ||
|
|
92
|
+
searchEntries.length < 1 ||
|
|
93
|
+
!searchEntries[0] ||
|
|
94
|
+
!searchEntries[0].dn
|
|
95
|
+
) {
|
|
96
|
+
user = null
|
|
145
97
|
} else {
|
|
146
|
-
user = searchEntries[0]
|
|
98
|
+
user = searchEntries[0]
|
|
147
99
|
}
|
|
148
100
|
|
|
149
|
-
return user
|
|
101
|
+
return user
|
|
150
102
|
}
|
|
151
103
|
|
|
152
104
|
// search a groups which user is member
|
|
@@ -162,10 +114,16 @@ async function _searchUserGroups(
|
|
|
162
114
|
// const filter = `(&(objectclass=${groupClass})(${groupMemberAttribute}=${user[groupMemberUserAttribute]}))`
|
|
163
115
|
const filter = new ldapts.AndFilter({
|
|
164
116
|
filters: [
|
|
165
|
-
new ldapts.EqualityFilter({
|
|
166
|
-
|
|
117
|
+
new ldapts.EqualityFilter({
|
|
118
|
+
attribute: 'objectclass',
|
|
119
|
+
value: groupClass,
|
|
120
|
+
}),
|
|
121
|
+
new ldapts.EqualityFilter({
|
|
122
|
+
attribute: groupMemberAttribute,
|
|
123
|
+
value: user[groupMemberUserAttribute],
|
|
124
|
+
}),
|
|
167
125
|
],
|
|
168
|
-
})
|
|
126
|
+
})
|
|
169
127
|
|
|
170
128
|
const { searchEntries, searchReferences } = await ldapClient.search(
|
|
171
129
|
searchBase,
|
|
@@ -173,13 +131,13 @@ async function _searchUserGroups(
|
|
|
173
131
|
filter: filter,
|
|
174
132
|
scope: 'sub',
|
|
175
133
|
}
|
|
176
|
-
)
|
|
134
|
+
)
|
|
177
135
|
|
|
178
|
-
let groups
|
|
179
|
-
if(!searchEntries || searchEntries.length < 1) {
|
|
180
|
-
groups = []
|
|
136
|
+
let groups
|
|
137
|
+
if (!searchEntries || searchEntries.length < 1) {
|
|
138
|
+
groups = []
|
|
181
139
|
} else {
|
|
182
|
-
groups = searchEntries
|
|
140
|
+
groups = searchEntries
|
|
183
141
|
}
|
|
184
142
|
// ldapjs has group.objectName, ldapts does not have it. instead, use dn
|
|
185
143
|
// add objectName back for backward compatibility
|
|
@@ -188,7 +146,7 @@ async function _searchUserGroups(
|
|
|
188
146
|
group.objectName = group.dn
|
|
189
147
|
}
|
|
190
148
|
}
|
|
191
|
-
return groups
|
|
149
|
+
return groups
|
|
192
150
|
}
|
|
193
151
|
|
|
194
152
|
async function authenticateWithAdmin(
|
|
@@ -215,7 +173,10 @@ async function authenticateWithAdmin(
|
|
|
215
173
|
ldapOpts
|
|
216
174
|
)
|
|
217
175
|
} catch (error) {
|
|
218
|
-
|
|
176
|
+
if (ldapAdminClient && ldapAdminClient.isConnected) {
|
|
177
|
+
await ldapAdminClient.unbind()
|
|
178
|
+
}
|
|
179
|
+
throw new LdapAuthenticationError(error)
|
|
219
180
|
}
|
|
220
181
|
let user = await _searchUser(
|
|
221
182
|
ldapAdminClient,
|
|
@@ -229,6 +190,7 @@ async function authenticateWithAdmin(
|
|
|
229
190
|
ldapOpts.log.trace(
|
|
230
191
|
`admin did not find user! (${usernameAttribute}=${username})`
|
|
231
192
|
)
|
|
193
|
+
await ldapAdminClient.unbind()
|
|
232
194
|
throw new LdapAuthenticationError(
|
|
233
195
|
'user not found or usernameAttribute is wrong'
|
|
234
196
|
)
|
|
@@ -238,7 +200,10 @@ async function authenticateWithAdmin(
|
|
|
238
200
|
try {
|
|
239
201
|
ldapUserClient = await _ldapBind(userDn, userPassword, starttls, ldapOpts)
|
|
240
202
|
} catch (error) {
|
|
241
|
-
|
|
203
|
+
if (ldapUserClient && ldapUserClient.isConnected) {
|
|
204
|
+
await ldapUserClient.unbind()
|
|
205
|
+
}
|
|
206
|
+
throw new LdapAuthenticationError(error)
|
|
242
207
|
}
|
|
243
208
|
if (groupsSearchBase && groupClass && groupMemberAttribute) {
|
|
244
209
|
let groups = await _searchUserGroups(
|
|
@@ -251,6 +216,8 @@ async function authenticateWithAdmin(
|
|
|
251
216
|
)
|
|
252
217
|
user.groups = groups
|
|
253
218
|
}
|
|
219
|
+
await ldapAdminClient.unbind()
|
|
220
|
+
await ldapUserClient.unbind()
|
|
254
221
|
return user
|
|
255
222
|
}
|
|
256
223
|
|
|
@@ -272,10 +239,14 @@ async function authenticateWithUser(
|
|
|
272
239
|
try {
|
|
273
240
|
ldapUserClient = await _ldapBind(userDn, userPassword, starttls, ldapOpts)
|
|
274
241
|
} catch (error) {
|
|
275
|
-
|
|
242
|
+
if (ldapUserClient && ldapUserClient.isConnected) {
|
|
243
|
+
await ldapUserClient.unbind()
|
|
244
|
+
}
|
|
245
|
+
throw new LdapAuthenticationError(error)
|
|
276
246
|
}
|
|
277
247
|
if (!usernameAttribute || !userSearchBase) {
|
|
278
248
|
// if usernameAttribute is not provided, no user detail is needed.
|
|
249
|
+
await ldapUserClient.unbind()
|
|
279
250
|
return true
|
|
280
251
|
}
|
|
281
252
|
let user = await _searchUser(
|
|
@@ -290,6 +261,7 @@ async function authenticateWithUser(
|
|
|
290
261
|
ldapOpts.log.trace(
|
|
291
262
|
`user logged in, but user details could not be found. (${usernameAttribute}=${username}). Probabaly wrong attribute or searchBase?`
|
|
292
263
|
)
|
|
264
|
+
await ldapUserClient.unbind()
|
|
293
265
|
throw new LdapAuthenticationError(
|
|
294
266
|
'user logged in, but user details could not be found. Probabaly usernameAttribute or userSearchBase is wrong?'
|
|
295
267
|
)
|
|
@@ -305,6 +277,7 @@ async function authenticateWithUser(
|
|
|
305
277
|
)
|
|
306
278
|
user.groups = groups
|
|
307
279
|
}
|
|
280
|
+
await ldapUserClient.unbind()
|
|
308
281
|
return user
|
|
309
282
|
}
|
|
310
283
|
|
|
@@ -331,7 +304,10 @@ async function verifyUserExists(
|
|
|
331
304
|
ldapOpts
|
|
332
305
|
)
|
|
333
306
|
} catch (error) {
|
|
334
|
-
|
|
307
|
+
if (ldapAdminClient && ldapAdminClient.isConnected) {
|
|
308
|
+
await ldapAdminClient.unbind()
|
|
309
|
+
}
|
|
310
|
+
throw new LdapAuthenticationError(error)
|
|
335
311
|
}
|
|
336
312
|
let user = await _searchUser(
|
|
337
313
|
ldapAdminClient,
|
|
@@ -345,6 +321,7 @@ async function verifyUserExists(
|
|
|
345
321
|
ldapOpts.log.trace(
|
|
346
322
|
`admin did not find user! (${usernameAttribute}=${username})`
|
|
347
323
|
)
|
|
324
|
+
await ldapAdminClient.unbind()
|
|
348
325
|
throw new LdapAuthenticationError(
|
|
349
326
|
'user not found or usernameAttribute is wrong'
|
|
350
327
|
)
|
|
@@ -360,6 +337,7 @@ async function verifyUserExists(
|
|
|
360
337
|
)
|
|
361
338
|
user.groups = groups
|
|
362
339
|
}
|
|
340
|
+
await ldapAdminClient.unbind()
|
|
363
341
|
return user
|
|
364
342
|
}
|
|
365
343
|
|
|
@@ -456,8 +434,5 @@ module.exports.authenticate = authenticate
|
|
|
456
434
|
module.exports.LdapAuthenticationError = LdapAuthenticationError
|
|
457
435
|
|
|
458
436
|
module.exports.exportForTesting = {
|
|
459
|
-
|
|
460
|
-
_parseEscapedHexToUtf8,
|
|
461
|
-
_recursiveParseHexString,
|
|
462
|
-
_ldapEscapeDN
|
|
437
|
+
_ldapEscapeDN,
|
|
463
438
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ldap-authentication",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.3",
|
|
4
4
|
"description": "A simple async nodejs library for LDAP user authentication",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"ldap",
|
|
16
16
|
"authenticate",
|
|
17
17
|
"authentication",
|
|
18
|
-
"
|
|
18
|
+
"ldapts",
|
|
19
19
|
"security",
|
|
20
20
|
"simple",
|
|
21
21
|
"lightweight",
|
package/test/string.spec.js
CHANGED
|
@@ -1,62 +1,21 @@
|
|
|
1
1
|
const { exportForTesting } = require('../index.js')
|
|
2
|
-
const {
|
|
3
|
-
exportForTesting
|
|
2
|
+
const { _ldapEscapeDN } = exportForTesting
|
|
4
3
|
|
|
5
|
-
describe('
|
|
6
|
-
it('unescape string test', () => {
|
|
7
|
-
let s =
|
|
8
|
-
'cn=\\e7\\a0\\94\\e5\\8f\\91A\\e9\\83\\a8,ou=users,dc=example,dc=com'
|
|
9
|
-
let us = _parseEscapedHexToUtf8(s)
|
|
10
|
-
expect(us).toEqual('cn=研发A部,ou=users,dc=example,dc=com')
|
|
11
|
-
})
|
|
12
|
-
it('unescape string test2', () => {
|
|
13
|
-
let s =
|
|
14
|
-
'cn=\\e7\\a0\\94\\e5\\8f\\91A\\e9\\83\\a8\\c2\\a9,ou=users,dc=example,dc=com'
|
|
15
|
-
let us = _parseEscapedHexToUtf8(s)
|
|
16
|
-
expect(us).toEqual('cn=研发A部©,ou=users,dc=example,dc=com')
|
|
17
|
-
})
|
|
18
|
-
it('unescape string test3', () => {
|
|
19
|
-
let s = 'cn=ABC,ou=users,dc=example,dc=com'
|
|
20
|
-
let us = _parseEscapedHexToUtf8(s)
|
|
21
|
-
expect(us).toEqual('cn=ABC,ou=users,dc=example,dc=com')
|
|
22
|
-
})
|
|
23
|
-
it('convert obj', () => {
|
|
24
|
-
let target = {
|
|
25
|
-
a: ['研发A部©', 'abc'],
|
|
26
|
-
b: 'xyz',
|
|
27
|
-
c: true,
|
|
28
|
-
d: null,
|
|
29
|
-
e: '研发A部©',
|
|
30
|
-
f: 1000,
|
|
31
|
-
}
|
|
32
|
-
let obj = {
|
|
33
|
-
a: ['\\e7\\a0\\94\\e5\\8f\\91A\\e9\\83\\a8\\c2\\a9', 'abc'],
|
|
34
|
-
b: 'xyz',
|
|
35
|
-
c: true,
|
|
36
|
-
d: null,
|
|
37
|
-
e: '\\e7\\a0\\94\\e5\\8f\\91A\\e9\\83\\a8\\c2\\a9',
|
|
38
|
-
f: 1000,
|
|
39
|
-
}
|
|
40
|
-
let converted = _recursiveParseHexString(obj)
|
|
41
|
-
expect(converted).toEqual(target)
|
|
42
|
-
})
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
describe('escape , in the DN test', ()=>{
|
|
4
|
+
describe('escape , in the DN test', () => {
|
|
46
5
|
let cases = [
|
|
47
|
-
{ s:
|
|
48
|
-
{ s:
|
|
49
|
-
{ s:
|
|
50
|
-
{ s:
|
|
51
|
-
{ s:
|
|
52
|
-
{ s:
|
|
53
|
-
{ s:
|
|
6
|
+
{ s: 'a', want: 'a' },
|
|
7
|
+
{ s: '', want: '' },
|
|
8
|
+
{ s: 'CN=a,DN=b', want: 'CN=a,DN=b' },
|
|
9
|
+
{ s: 'CN=a, c,DN=b', want: 'CN=a\\, c,DN=b' },
|
|
10
|
+
{ s: 'CN=a\\, c,DN=b', want: 'CN=a\\, c,DN=b' },
|
|
11
|
+
{ s: 'CN=a, b, c,DN=b', want: 'CN=a\\, b\\, c,DN=b' },
|
|
12
|
+
{ s: 'CN=a, c', want: 'CN=a\\, c' },
|
|
54
13
|
]
|
|
55
14
|
for (let c of cases) {
|
|
56
|
-
it(
|
|
15
|
+
it('escape ' + c.s, () => {
|
|
57
16
|
let got = _ldapEscapeDN(c.s)
|
|
58
17
|
expect(got).toEqual(c.want)
|
|
59
18
|
})
|
|
60
|
-
|
|
61
19
|
}
|
|
62
|
-
})
|
|
20
|
+
})
|
|
21
|
+
|