mybase 1.1.51 → 1.2.2
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/ip6addr.d.ts +10 -1
- package/jest.config.js +8 -1
- package/mybase.d.ts +57 -0
- package/mybase.js +401 -684
- package/mybase.test.ts +647 -0
- package/mybase.ts +397 -0
- package/package.json +3 -2
- package/ts/funcs/Geoip2Paths.js +7 -5
- package/ts/funcs/Geoip2Paths.ts +6 -4
- package/ts/funcs/asJSON.d.ts +1 -1
- package/ts/funcs/asJSON.js +6 -4
- package/ts/funcs/asJSON.ts +8 -5
- package/ts/funcs/hash_sha512.d.ts +1 -1
- package/ts/funcs/hash_sha512.js +4 -4
- package/ts/funcs/hash_sha512.ts +3 -4
- package/ts/funcs/isLANIp.d.ts +2 -3
- package/ts/funcs/isLANIp.js +14 -15
- package/ts/funcs/isLANIp.test.ts +7 -8
- package/ts/funcs/isLANIp.ts +25 -28
- package/ts/funcs/isLoopbackIP.d.ts +2 -3
- package/ts/funcs/isLoopbackIP.js +15 -16
- package/ts/funcs/isLoopbackIP.test.ts +7 -7
- package/ts/funcs/isLoopbackIP.ts +21 -23
- package/ts/funcs/validEmail.d.ts +1 -1
- package/ts/funcs/validEmail.js +0 -3
- package/ts/funcs/validEmail.ts +1 -3
- package/ts/funcs/vaultFill.js +1 -1
- package/ts/funcs/vaultFill.ts +1 -1
- package/ts/funcs/vaultRead.js +9 -3
- package/ts/funcs/vaultRead.ts +8 -3
- package/ts/index.d.ts +1 -0
- package/ts/index.js +1 -0
- package/ts/index.ts +1 -1
- package/ts/models/DateIterator.d.ts +33 -0
- package/ts/models/DateIterator.js +76 -0
- package/ts/models/DateIterator.test.ts +149 -0
- package/ts/models/DateIterator.ts +80 -0
- package/ts/models/IPAddress.d.ts +13 -13
- package/ts/models/IPAddress.ts +4 -4
- package/ts/models/OTPGenerator.test.ts +1 -1
- package/ts/types.d.ts +35 -0
- package/ts/types.js +1 -0
- package/ts/types.ts +42 -1
- package/tsconfig.jest.json +11 -0
- package/tsconfig.json +2 -1
- package/types/third-party.d.ts +21 -0
package/mybase.test.ts
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
// @ts-nocheck — jest mocks stay loosely typed; assertions are unchanged
|
|
2
|
+
//#region imports
|
|
3
|
+
import * as fs from 'fs'
|
|
4
|
+
import * as os from 'os'
|
|
5
|
+
import * as path from 'path'
|
|
6
|
+
import * as net from 'net'
|
|
7
|
+
import { afterEach, describe, expect, it, jest } from '@jest/globals'
|
|
8
|
+
import type { AddressInfo } from 'net'
|
|
9
|
+
//#endregion
|
|
10
|
+
|
|
11
|
+
jest.mock('@maxmind/geoip2-node', () => ({
|
|
12
|
+
Reader: {
|
|
13
|
+
open: jest.fn().mockImplementation((..._args: unknown[]) =>
|
|
14
|
+
Promise.resolve({ kind: 'mock-reader' })
|
|
15
|
+
),
|
|
16
|
+
},
|
|
17
|
+
}), { virtual: true })
|
|
18
|
+
|
|
19
|
+
jest.mock('ip-range-check', () => {
|
|
20
|
+
const fn = jest.requireActual('ip-range-check')
|
|
21
|
+
return { __esModule: true, default: fn }
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
//#region module under test
|
|
25
|
+
import {
|
|
26
|
+
encryptAES_CBC_NOIV,
|
|
27
|
+
decryptAES_CBC_NOIV,
|
|
28
|
+
ip2int,
|
|
29
|
+
int2ip,
|
|
30
|
+
canReadAndWrite,
|
|
31
|
+
softexit,
|
|
32
|
+
randomString,
|
|
33
|
+
randomBase32,
|
|
34
|
+
randomAuthcode,
|
|
35
|
+
validAuthcode,
|
|
36
|
+
vaultRead,
|
|
37
|
+
vaultFill,
|
|
38
|
+
isLocal,
|
|
39
|
+
isObject,
|
|
40
|
+
maxmindOpen,
|
|
41
|
+
validHPassword,
|
|
42
|
+
validEmail,
|
|
43
|
+
validHostname,
|
|
44
|
+
validHostname2,
|
|
45
|
+
validIp,
|
|
46
|
+
validIpNative,
|
|
47
|
+
validUUID4,
|
|
48
|
+
getTemp,
|
|
49
|
+
getMysql,
|
|
50
|
+
sqlQuery,
|
|
51
|
+
validURL,
|
|
52
|
+
randomIP,
|
|
53
|
+
wait,
|
|
54
|
+
validTime,
|
|
55
|
+
removeDoubleSlashes,
|
|
56
|
+
array_shuffle,
|
|
57
|
+
object_shuffle,
|
|
58
|
+
isMochaRunning,
|
|
59
|
+
portCheck_tcp,
|
|
60
|
+
asJSON,
|
|
61
|
+
ensureProperty,
|
|
62
|
+
arrayRandomItem,
|
|
63
|
+
promiseTimeout,
|
|
64
|
+
utcnow,
|
|
65
|
+
Geoip2Paths,
|
|
66
|
+
randomHPassword,
|
|
67
|
+
isURL,
|
|
68
|
+
hash_sha512,
|
|
69
|
+
isReservedLANIP,
|
|
70
|
+
isLANIp,
|
|
71
|
+
isLoopbackIP,
|
|
72
|
+
MaxRuntimeHours,
|
|
73
|
+
} from './mybase.js'
|
|
74
|
+
//#endregion
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
describe('mybase', () => {
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
jest.restoreAllMocks()
|
|
80
|
+
jest.clearAllMocks()
|
|
81
|
+
jest.useRealTimers()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('hash_sha512', () => {
|
|
85
|
+
it('hashes strings', () => {
|
|
86
|
+
const h = hash_sha512('x') as string
|
|
87
|
+
expect(h).toHaveLength(128)
|
|
88
|
+
expect(/^[a-f0-9]+$/i.test(h)).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
it('returns false for non-string', () => {
|
|
91
|
+
expect(hash_sha512(null as unknown as string)).toBe(false)
|
|
92
|
+
expect(hash_sha512(1 as unknown as string)).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('isLoopbackIP / isLANIp', () => {
|
|
97
|
+
it('isLoopbackIP handles loopback and rejects invalid', () => {
|
|
98
|
+
expect(isLoopbackIP('127.0.0.1')).toBe(true)
|
|
99
|
+
expect(isLoopbackIP('::1')).toBe(true)
|
|
100
|
+
expect(isLoopbackIP('::ffff:127.0.0.1')).toBe(true)
|
|
101
|
+
expect(isLoopbackIP('8.8.8.8')).toBeNull()
|
|
102
|
+
expect(isLoopbackIP('not-an-ip')).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
it('isLANIp handles private ranges', () => {
|
|
105
|
+
expect(isLANIp('10.0.0.1')).toBe(true)
|
|
106
|
+
expect(isLANIp('192.168.1.1')).toBe(true)
|
|
107
|
+
expect(isLANIp('8.8.8.8')).toBeNull()
|
|
108
|
+
expect(isLANIp('bad')).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('validHPassword / randomHPassword', () => {
|
|
113
|
+
it('validates 128-char hex', () => {
|
|
114
|
+
expect(validHPassword('')).toBeFalsy()
|
|
115
|
+
expect(validHPassword('ab')).toBe(false)
|
|
116
|
+
const good = 'a'.repeat(128)
|
|
117
|
+
expect(validHPassword(good)).toBe(true)
|
|
118
|
+
expect(validHPassword('g'.repeat(128))).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
it('randomHPassword returns valid hash', () => {
|
|
121
|
+
const h = randomHPassword(8)
|
|
122
|
+
expect(validHPassword(h)).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('validEmail', () => {
|
|
127
|
+
it('validates via validator', () => {
|
|
128
|
+
expect(validEmail('a@b.co')).toBe(true)
|
|
129
|
+
expect(validEmail('not-email')).toBe(false)
|
|
130
|
+
expect(validEmail(null as unknown as string)).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('arrayRandomItem', () => {
|
|
135
|
+
it('picks from array or returns default', () => {
|
|
136
|
+
jest.spyOn(Math, 'random').mockReturnValue(0.99)
|
|
137
|
+
expect(arrayRandomItem([1, 2, 3], 'd')).toBe(3)
|
|
138
|
+
expect(arrayRandomItem('nope' as unknown as unknown[], 'd')).toBe('d')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('utcnow', () => {
|
|
143
|
+
it('returns unix seconds', () => {
|
|
144
|
+
jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_123)
|
|
145
|
+
expect(utcnow()).toBe(1_700_000_000)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('validIp (deprecated)', () => {
|
|
150
|
+
it('matches dotted quads and warns', () => {
|
|
151
|
+
const spy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
152
|
+
expect(validIp('192.168.0.1')).toBe(true)
|
|
153
|
+
expect(validIp('999.1.1.1')).toBe(false)
|
|
154
|
+
expect(validIp(null as unknown as string)).toBe(false)
|
|
155
|
+
expect(spy).toHaveBeenCalled()
|
|
156
|
+
spy.mockRestore()
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('Geoip2Paths', () => {
|
|
161
|
+
it('returns path map with boolean or string entries', () => {
|
|
162
|
+
const paths = Geoip2Paths()
|
|
163
|
+
expect(paths).toEqual(
|
|
164
|
+
expect.objectContaining({
|
|
165
|
+
country: expect.anything(),
|
|
166
|
+
city: expect.anything(),
|
|
167
|
+
isp: expect.anything(),
|
|
168
|
+
ct: expect.anything(),
|
|
169
|
+
})
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('validIpNative / randomIP / int2ip / ip2int', () => {
|
|
175
|
+
it('validIpNative uses net.isIP', () => {
|
|
176
|
+
expect(validIpNative('1.1.1.1')).toBe(true)
|
|
177
|
+
expect(validIpNative('::1')).toBe(true)
|
|
178
|
+
expect(validIpNative('nope')).toBe(false)
|
|
179
|
+
})
|
|
180
|
+
it('randomIP returns dotted quad', () => {
|
|
181
|
+
expect(randomIP()).toMatch(/^\d+\.\d+\.\d+\.\d+$/)
|
|
182
|
+
})
|
|
183
|
+
it('int2ip / ip2int roundtrip for sample', () => {
|
|
184
|
+
const ip = '10.20.30.40'
|
|
185
|
+
expect(int2ip(ip2int(ip))).toBe(ip)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('isURL', () => {
|
|
190
|
+
it('parses valid URL strings', () => {
|
|
191
|
+
const u = isURL('https://example.com/path') as URL
|
|
192
|
+
expect(u.hostname).toBe('example.com')
|
|
193
|
+
expect(isURL(1 as unknown as string)).toBe(false)
|
|
194
|
+
expect(isURL('not a url')).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
describe('validTime', () => {
|
|
199
|
+
it('accepts 10-digit positive unix time', () => {
|
|
200
|
+
expect(validTime('1234567890')).toBe(1234567890)
|
|
201
|
+
expect(validTime('123')).toBe(false)
|
|
202
|
+
expect(validTime('')).toBe(false)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('validURL', () => {
|
|
207
|
+
it('delegates to @7c/validurl', () => {
|
|
208
|
+
expect(validURL('https://example.com')).toBe(true)
|
|
209
|
+
expect(validURL('')).toBe(false)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('asJSON', () => {
|
|
214
|
+
it('parses or returns false', () => {
|
|
215
|
+
expect(asJSON('{"a":1}')).toEqual({ a: 1 })
|
|
216
|
+
expect(asJSON('')).toBe(false)
|
|
217
|
+
expect(asJSON(null as unknown as string)).toBe(false)
|
|
218
|
+
expect(asJSON('{bad')).toBe(false)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('isObject', () => {
|
|
223
|
+
it('plain objects only', () => {
|
|
224
|
+
expect(isObject({})).toBe(true)
|
|
225
|
+
expect(isObject(null)).toBe(false)
|
|
226
|
+
expect(isObject([])).toBe(false)
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('getMysql', () => {
|
|
231
|
+
it('resolves client on connect success with keep_pinging', async () => {
|
|
232
|
+
jest.useFakeTimers()
|
|
233
|
+
const ping = jest.fn((cb: (e: Error | null) => void) => cb(null))
|
|
234
|
+
const client = {
|
|
235
|
+
ping,
|
|
236
|
+
connect: jest.fn((cb: (e: Error | null) => void) => cb(null)),
|
|
237
|
+
}
|
|
238
|
+
const mysqlClass = { createConnection: jest.fn(() => client) }
|
|
239
|
+
const p = getMysql(mysqlClass as never, {
|
|
240
|
+
host: 'h',
|
|
241
|
+
login: 'u',
|
|
242
|
+
password: 'p',
|
|
243
|
+
db: 'd',
|
|
244
|
+
}, true)
|
|
245
|
+
const c = await p
|
|
246
|
+
expect(c).toBe(client)
|
|
247
|
+
jest.advanceTimersByTime(30_000)
|
|
248
|
+
await Promise.resolve()
|
|
249
|
+
expect(ping).toHaveBeenCalled()
|
|
250
|
+
})
|
|
251
|
+
it('rejects on connect error', async () => {
|
|
252
|
+
const client = {
|
|
253
|
+
ping: jest.fn(),
|
|
254
|
+
connect: jest.fn((cb: (e: Error | null) => void) => cb(new Error('e'))),
|
|
255
|
+
}
|
|
256
|
+
const mysqlClass = { createConnection: jest.fn(() => client) }
|
|
257
|
+
await expect(getMysql(mysqlClass as never, {
|
|
258
|
+
host: 'h',
|
|
259
|
+
login: 'u',
|
|
260
|
+
password: 'p',
|
|
261
|
+
db: 'd',
|
|
262
|
+
}, false)).rejects.toThrow('e')
|
|
263
|
+
})
|
|
264
|
+
it('omit keep_pinging skips interval', async () => {
|
|
265
|
+
jest.useFakeTimers()
|
|
266
|
+
const ping = jest.fn()
|
|
267
|
+
const client = {
|
|
268
|
+
ping,
|
|
269
|
+
connect: jest.fn((cb: (e: Error | null) => void) => cb(null)),
|
|
270
|
+
}
|
|
271
|
+
const mysqlClass = { createConnection: jest.fn(() => client) }
|
|
272
|
+
await getMysql(mysqlClass as never, {
|
|
273
|
+
host: 'h',
|
|
274
|
+
port: 3307,
|
|
275
|
+
login: 'u',
|
|
276
|
+
password: 'p',
|
|
277
|
+
db: 'd',
|
|
278
|
+
}, false)
|
|
279
|
+
jest.advanceTimersByTime(60_000)
|
|
280
|
+
expect(ping).not.toHaveBeenCalled()
|
|
281
|
+
})
|
|
282
|
+
it('pinger logs when ping returns error', async () => {
|
|
283
|
+
jest.useFakeTimers()
|
|
284
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
285
|
+
const ping = jest.fn((cb: (e: Error | null) => void) => cb(Object.assign(new Error('p'), { code: 'PROTO' })))
|
|
286
|
+
const client = {
|
|
287
|
+
ping,
|
|
288
|
+
connect: jest.fn((cb: (e: Error | null) => void) => cb(null)),
|
|
289
|
+
}
|
|
290
|
+
const mysqlClass = { createConnection: jest.fn(() => client) }
|
|
291
|
+
await getMysql(mysqlClass as never, {
|
|
292
|
+
host: 'h',
|
|
293
|
+
login: 'u',
|
|
294
|
+
password: 'p',
|
|
295
|
+
db: 'd',
|
|
296
|
+
}, true)
|
|
297
|
+
jest.advanceTimersByTime(30_000)
|
|
298
|
+
expect(logSpy.mock.calls.some((c) => String(c[0]).includes('could not ping mysql'))).toBe(true)
|
|
299
|
+
logSpy.mockRestore()
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe('fileCacheIsValid (via vaultRead)', () => {
|
|
304
|
+
it('uses fresh cache when file recent', async () => {
|
|
305
|
+
const vault = { read: jest.fn() }
|
|
306
|
+
const key = `__cache_hit_${randomString(8)}__`
|
|
307
|
+
const cacheKey = path.join('/var/tmp/vault-cache', hash_sha512(`undefined/${key}`) as string)
|
|
308
|
+
fs.mkdirSync(path.dirname(cacheKey), { recursive: true })
|
|
309
|
+
fs.writeFileSync(cacheKey, JSON.stringify({ ok: true }))
|
|
310
|
+
const recent = new Date()
|
|
311
|
+
fs.utimesSync(cacheKey, recent, recent)
|
|
312
|
+
const data = await vaultRead(vault as never, key, 60)
|
|
313
|
+
expect(data).toEqual({ ok: true })
|
|
314
|
+
expect(vault.read).not.toHaveBeenCalled()
|
|
315
|
+
fs.unlinkSync(cacheKey)
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('vaultRead', () => {
|
|
320
|
+
it('resolves vault data and can write cache', async () => {
|
|
321
|
+
const key = `__vault_ok_${randomString(8)}__`
|
|
322
|
+
const vault = {
|
|
323
|
+
read: jest.fn().mockResolvedValue({ data: { secret: 1 } }),
|
|
324
|
+
}
|
|
325
|
+
const data = await vaultRead(vault as never, key, 0)
|
|
326
|
+
expect(data).toEqual({ secret: 1 })
|
|
327
|
+
expect(vault.read).toHaveBeenCalledWith(key)
|
|
328
|
+
})
|
|
329
|
+
it('rejects when no data', async () => {
|
|
330
|
+
const vault = { read: jest.fn().mockResolvedValue({}) }
|
|
331
|
+
await expect(vaultRead(vault as never, 'k', 0)).rejects.toEqual({})
|
|
332
|
+
})
|
|
333
|
+
it('on error falls back to cache when present', async () => {
|
|
334
|
+
const key = `__vault_fb_${randomString(8)}__`
|
|
335
|
+
const payload = { from: 'cache' }
|
|
336
|
+
const cacheKey = path.join('/var/tmp/vault-cache', hash_sha512(`undefined/${key}`) as string)
|
|
337
|
+
fs.mkdirSync(path.dirname(cacheKey), { recursive: true })
|
|
338
|
+
fs.writeFileSync(cacheKey, JSON.stringify(payload))
|
|
339
|
+
const vault = { read: jest.fn().mockRejectedValue(new Error('network')) }
|
|
340
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
341
|
+
const data = await vaultRead(vault as never, key, 10)
|
|
342
|
+
expect(data).toEqual(payload)
|
|
343
|
+
logSpy.mockRestore()
|
|
344
|
+
fs.unlinkSync(cacheKey)
|
|
345
|
+
})
|
|
346
|
+
it('rejects when error and cache missing', async () => {
|
|
347
|
+
const vault = { read: jest.fn().mockRejectedValue(new Error('fail')) }
|
|
348
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
349
|
+
await expect(vaultRead(vault as never, `__missing_${randomString(8)}__`, 10)).rejects.toThrow('fail')
|
|
350
|
+
logSpy.mockRestore()
|
|
351
|
+
})
|
|
352
|
+
it('when error and cache file has invalid JSON, logs parse error and rejects', async () => {
|
|
353
|
+
const key = `__vault_badjson_${randomString(8)}__`
|
|
354
|
+
const cacheKey = path.join('/var/tmp/vault-cache', hash_sha512(`undefined/${key}`) as string)
|
|
355
|
+
fs.mkdirSync(path.dirname(cacheKey), { recursive: true })
|
|
356
|
+
fs.writeFileSync(cacheKey, 'not-json')
|
|
357
|
+
const vault = { read: jest.fn().mockRejectedValue(new Error('vault down')) }
|
|
358
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
359
|
+
await expect(vaultRead(vault as never, key, 0)).rejects.toThrow('vault down')
|
|
360
|
+
expect(logSpy.mock.calls.some((args) => String(args[0]).includes('not valid JSON') || String(args[0]).includes('SyntaxError'))).toBe(true)
|
|
361
|
+
logSpy.mockRestore()
|
|
362
|
+
fs.unlinkSync(cacheKey)
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
describe('vaultFill', () => {
|
|
367
|
+
it('replaces vault@ strings and nested objects', async () => {
|
|
368
|
+
const vault = {
|
|
369
|
+
read: jest.fn().mockImplementation((k: string) =>
|
|
370
|
+
Promise.resolve({ data: { v: k, __vaultkey: k, __vaultfilled: 1 } })
|
|
371
|
+
),
|
|
372
|
+
}
|
|
373
|
+
const cfg = {
|
|
374
|
+
a: 'vault@secret/db',
|
|
375
|
+
nested: { __vaultkey: 'secret/db', x: 1 },
|
|
376
|
+
}
|
|
377
|
+
await vaultFill(vault as never, cfg, true, true)
|
|
378
|
+
expect(typeof cfg.a).toBe('object')
|
|
379
|
+
expect(cfg.nested).toHaveProperty('v', 'secret/db')
|
|
380
|
+
})
|
|
381
|
+
it('ignoreError stores false when read fails', async () => {
|
|
382
|
+
const vault = { read: jest.fn().mockRejectedValue(new Error('x')) }
|
|
383
|
+
const cfg = { a: 'vault@missing/key' }
|
|
384
|
+
await vaultFill(vault as never, cfg, false, false)
|
|
385
|
+
expect(cfg.a).toBe(false)
|
|
386
|
+
})
|
|
387
|
+
it('ignoreError true leaves vault@ value undefined when read fails', async () => {
|
|
388
|
+
const vault = { read: jest.fn().mockRejectedValue(new Error('x')) }
|
|
389
|
+
const cfg = { a: 'vault@bad/key' }
|
|
390
|
+
await vaultFill(vault as never, cfg, true, false)
|
|
391
|
+
expect(cfg.a).toBeUndefined()
|
|
392
|
+
})
|
|
393
|
+
it('keepCache false overwrites __vaultkey object even when result missing', async () => {
|
|
394
|
+
const vault = {
|
|
395
|
+
read: jest.fn().mockResolvedValue({ data: { z: 1 } }),
|
|
396
|
+
}
|
|
397
|
+
const cfg = { block: { __vaultkey: 'secret/x', keep: true } }
|
|
398
|
+
await vaultFill(vault as never, cfg, false, false)
|
|
399
|
+
expect(cfg.block).toMatchObject({ z: 1, __vaultkey: 'secret/x' })
|
|
400
|
+
})
|
|
401
|
+
it('fills vault@ strings nested in arrays', async () => {
|
|
402
|
+
const vault = {
|
|
403
|
+
read: jest.fn().mockResolvedValue({ data: { val: 99 } }),
|
|
404
|
+
}
|
|
405
|
+
const cfg = { items: ['vault@arr/key'] }
|
|
406
|
+
await vaultFill(vault as never, cfg, false, false)
|
|
407
|
+
expect(cfg.items[0]).toMatchObject({ val: 99, __vaultkey: 'arr/key' })
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
describe('maxmindOpen', () => {
|
|
412
|
+
it('resolves reader', async () => {
|
|
413
|
+
const r = await maxmindOpen('/tmp/fake.mmdb')
|
|
414
|
+
expect(r).toEqual({ kind: 'mock-reader' })
|
|
415
|
+
})
|
|
416
|
+
it('rejects when Reader.open rejects', async () => {
|
|
417
|
+
const geo = require('@maxmind/geoip2-node') as { Reader: { open: jest.Mock } }
|
|
418
|
+
geo.Reader.open.mockRejectedValueOnce(new Error('open-fail'))
|
|
419
|
+
await expect(maxmindOpen('/bad')).rejects.toThrow('open-fail')
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
describe('isLocal', () => {
|
|
424
|
+
it('is true iff os.type() is Darwin', () => {
|
|
425
|
+
expect(isLocal()).toBe(os.type() === 'Darwin')
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
describe('randomBase32', () => {
|
|
430
|
+
it('respects length', () => {
|
|
431
|
+
expect(randomBase32(4)).toHaveLength(4)
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
describe('validAuthcode / randomAuthcode', () => {
|
|
436
|
+
it('random passes validation', () => {
|
|
437
|
+
for (let i = 0; i < 20; i++) {
|
|
438
|
+
const code = randomAuthcode()
|
|
439
|
+
expect(validAuthcode(code)).toBe(true)
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
it('rejects malformed', () => {
|
|
443
|
+
expect(validAuthcode('')).toBe(false)
|
|
444
|
+
expect(validAuthcode('abcdefghijkl')).toBe(false)
|
|
445
|
+
expect(validAuthcode('x-xx-xx-xx-xx')).toBe(false)
|
|
446
|
+
})
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
describe('array_shuffle / object_shuffle', () => {
|
|
450
|
+
it('shuffles in place and object keys', () => {
|
|
451
|
+
jest.spyOn(Math, 'random').mockReturnValue(0.42)
|
|
452
|
+
const a = [1, 2, 3]
|
|
453
|
+
array_shuffle(a)
|
|
454
|
+
expect(a).toHaveLength(3)
|
|
455
|
+
const o = object_shuffle({ a: 1, b: 2 })
|
|
456
|
+
expect(Object.keys(o).sort()).toEqual(['a', 'b'])
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
describe('randomString', () => {
|
|
461
|
+
it('default charset length', () => {
|
|
462
|
+
expect(randomString(5)).toHaveLength(5)
|
|
463
|
+
})
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
describe('canReadAndWrite', () => {
|
|
467
|
+
it('resolves when directory rw', async () => {
|
|
468
|
+
const dir = path.join(os.tmpdir(), `rw_${randomString(6)}`)
|
|
469
|
+
await expect(canReadAndWrite(dir)).resolves.toBe(true)
|
|
470
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
471
|
+
})
|
|
472
|
+
it('creates directory when missing (ENOENT)', async () => {
|
|
473
|
+
const dir = path.join(os.tmpdir(), `rw_new_${randomString(10)}`)
|
|
474
|
+
expect(fs.existsSync(dir)).toBe(false)
|
|
475
|
+
await expect(canReadAndWrite(dir)).resolves.toBe(true)
|
|
476
|
+
expect(fs.existsSync(dir)).toBe(true)
|
|
477
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
478
|
+
})
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
describe('softexit', () => {
|
|
482
|
+
it('schedules process.exit', () => {
|
|
483
|
+
jest.useFakeTimers()
|
|
484
|
+
const exit = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never)
|
|
485
|
+
const log = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
486
|
+
softexit('bye', 2, 3)
|
|
487
|
+
jest.advanceTimersByTime(2000)
|
|
488
|
+
expect(exit).toHaveBeenCalledWith(3)
|
|
489
|
+
log.mockRestore()
|
|
490
|
+
exit.mockRestore()
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
describe('validHostname', () => {
|
|
495
|
+
it('valid labels', () => {
|
|
496
|
+
expect(validHostname('a.b.com')).toBe(true)
|
|
497
|
+
expect(validHostname('')).toBe(false)
|
|
498
|
+
expect(validHostname('a'.repeat(300))).toBe(false)
|
|
499
|
+
})
|
|
500
|
+
it('accepts hostname with trailing dot (strips for label check)', () => {
|
|
501
|
+
expect(validHostname('example.com.')).toBe(true)
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
describe('validHostname2', () => {
|
|
506
|
+
it('allows IPs and hostnames', () => {
|
|
507
|
+
expect(validHostname2('1.2.3.4')).toBe(true)
|
|
508
|
+
expect(validHostname2('[::1]')).toBe(true)
|
|
509
|
+
expect(validHostname2('42')).toBe(false)
|
|
510
|
+
expect(validHostname2('12345')).toBe(false)
|
|
511
|
+
expect(validHostname2('')).toBe(false)
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
describe('removeDoubleSlashes', () => {
|
|
516
|
+
it('collapses path slashes', () => {
|
|
517
|
+
expect(removeDoubleSlashes('http://host//a//b')).toBe('http://host/a/b')
|
|
518
|
+
expect(removeDoubleSlashes(1 as unknown as string)).toBe(false)
|
|
519
|
+
})
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
describe('getTemp', () => {
|
|
523
|
+
it('returns tmpdir and joins filename', () => {
|
|
524
|
+
const t = getTemp()
|
|
525
|
+
expect(typeof t).toBe('string')
|
|
526
|
+
expect(getTemp('x.txt')).toBe(path.join(t, 'x.txt'))
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
describe('sqlQuery', () => {
|
|
531
|
+
it('resolves rows', async () => {
|
|
532
|
+
const res = { rows: [] }
|
|
533
|
+
const handle = {
|
|
534
|
+
query: jest.fn((q: string, v: unknown[], cb: (e: Error | null, r: unknown) => void) => cb(null, res)),
|
|
535
|
+
}
|
|
536
|
+
await expect(sqlQuery(handle as never, 'SELECT 1', [])).resolves.toBe(res)
|
|
537
|
+
})
|
|
538
|
+
it('rejects on error', async () => {
|
|
539
|
+
const handle = {
|
|
540
|
+
query: jest.fn((_q: string, _v: unknown[], cb: (e: Error | null) => void) => cb(new Error('sql'))),
|
|
541
|
+
}
|
|
542
|
+
await expect(sqlQuery(handle as never, 'x', [])).rejects.toThrow('sql')
|
|
543
|
+
})
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
describe('wait', () => {
|
|
547
|
+
it('delays', async () => {
|
|
548
|
+
jest.useFakeTimers()
|
|
549
|
+
const p = wait(0.01)
|
|
550
|
+
jest.advanceTimersByTime(20)
|
|
551
|
+
await p
|
|
552
|
+
})
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
describe('isMochaRunning', () => {
|
|
556
|
+
it('returns boolean', () => {
|
|
557
|
+
expect(typeof isMochaRunning()).toBe('boolean')
|
|
558
|
+
})
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
describe('validUUID4', () => {
|
|
562
|
+
it('v4 pattern', () => {
|
|
563
|
+
expect(validUUID4('550e8400-e29b-41d4-a716-446655440000')).toBe(true)
|
|
564
|
+
expect(validUUID4('550e8400-e29b-31d4-a716-446655440000')).toBe(false)
|
|
565
|
+
})
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
describe('promiseTimeout', () => {
|
|
569
|
+
it('times out', async () => {
|
|
570
|
+
await expect(promiseTimeout(5, new Promise(() => {}))).rejects.toBe('TIMEDOUT')
|
|
571
|
+
})
|
|
572
|
+
it('resolves fast promise', async () => {
|
|
573
|
+
await expect(promiseTimeout(1000, Promise.resolve('ok'))).resolves.toBe('ok')
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
describe('portCheck_tcp', () => {
|
|
578
|
+
it('true when port accepts', async () => {
|
|
579
|
+
const srv = net.createServer()
|
|
580
|
+
await new Promise<void>((resolve) => srv.listen(0, '127.0.0.1', resolve))
|
|
581
|
+
const addr = srv.address() as AddressInfo
|
|
582
|
+
const ok = await portCheck_tcp(addr.port, { host: '127.0.0.1', timeout: 2000 })
|
|
583
|
+
expect(ok).toBe(true)
|
|
584
|
+
await new Promise<void>((resolve) => srv.close(() => resolve()))
|
|
585
|
+
})
|
|
586
|
+
it('false when connection fails', async () => {
|
|
587
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
588
|
+
const closed = await portCheck_tcp(43_321, { host: '127.0.0.1', timeout: 200 })
|
|
589
|
+
expect(closed).toBe(false)
|
|
590
|
+
logSpy.mockRestore()
|
|
591
|
+
})
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
describe('ensureProperty', () => {
|
|
595
|
+
it('walks dotted path', () => {
|
|
596
|
+
const o = { a: { b: { c: 7 } } }
|
|
597
|
+
expect(ensureProperty(o, 'a.b.c', 0)).toBe(7)
|
|
598
|
+
expect(ensureProperty(o, 'a.b.missing', 9)).toBe(9)
|
|
599
|
+
})
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
describe('AES', () => {
|
|
603
|
+
it('roundtrips', () => {
|
|
604
|
+
const key = '0123456789abcdef'
|
|
605
|
+
const plain = 'hello'
|
|
606
|
+
const enc = encryptAES_CBC_NOIV(plain, key) as string
|
|
607
|
+
expect(decryptAES_CBC_NOIV(enc, key)).toBe(plain)
|
|
608
|
+
})
|
|
609
|
+
it('errors return false', () => {
|
|
610
|
+
expect(encryptAES_CBC_NOIV('x', 'short')).toBe(false)
|
|
611
|
+
expect(decryptAES_CBC_NOIV('zz', '0123456789abcdef')).toBe(false)
|
|
612
|
+
})
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
describe('MaxRuntimeHours', () => {
|
|
616
|
+
it('exits after delay', () => {
|
|
617
|
+
jest.useFakeTimers()
|
|
618
|
+
const exit = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never)
|
|
619
|
+
const log = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
620
|
+
MaxRuntimeHours(0.000_001)
|
|
621
|
+
jest.advanceTimersByTime(10)
|
|
622
|
+
expect(exit).toHaveBeenCalledWith(0)
|
|
623
|
+
log.mockRestore()
|
|
624
|
+
exit.mockRestore()
|
|
625
|
+
})
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
describe('isReservedLANIP', () => {
|
|
629
|
+
it('detects reserved IPv4', () => {
|
|
630
|
+
expect(isReservedLANIP('10.0.0.1')).toBe(true)
|
|
631
|
+
expect(isReservedLANIP('8.8.8.8')).toBe(false)
|
|
632
|
+
})
|
|
633
|
+
it('handles IPv6 with ip6.normalize', () => {
|
|
634
|
+
expect(isReservedLANIP('fd00::1')).toBe(true)
|
|
635
|
+
})
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
describe('Date.prototype.yyyymmdd (side effect)', () => {
|
|
639
|
+
it('formats local calendar date', () => {
|
|
640
|
+
const d = new Date(2024, 0, 5, 12, 0, 0)
|
|
641
|
+
const y = d.getFullYear()
|
|
642
|
+
const m = `${d.getMonth() + 1}`.padStart(2, '0')
|
|
643
|
+
const day = `${d.getDate()}`.padStart(2, '0')
|
|
644
|
+
expect((d as Date & { yyyymmdd(): string }).yyyymmdd()).toBe(`${y}${m}${day}`)
|
|
645
|
+
})
|
|
646
|
+
})
|
|
647
|
+
})
|