@es-labs/jslib 0.0.1

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 (72) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +42 -0
  3. package/__test__/services.test.js +32 -0
  4. package/auth/index.js +226 -0
  5. package/auth/keyv.js +23 -0
  6. package/auth/knex.js +29 -0
  7. package/auth/redis.js +23 -0
  8. package/comms/email.js +123 -0
  9. package/comms/nexmo.js +44 -0
  10. package/comms/telegram.js +43 -0
  11. package/comms/telegram2/inbound.js +314 -0
  12. package/comms/telegram2/outbound.js +574 -0
  13. package/comms/webpush.js +60 -0
  14. package/config.js +37 -0
  15. package/express/controller/auth/oauth.js +39 -0
  16. package/express/controller/auth/oidc.js +87 -0
  17. package/express/controller/auth/own.js +100 -0
  18. package/express/controller/auth/saml.js +74 -0
  19. package/express/upload.js +48 -0
  20. package/index.js +1 -0
  21. package/iso/README.md +4 -0
  22. package/iso/__tests__/csv-utils.spec.js +128 -0
  23. package/iso/__tests__/datetime.spec.js +101 -0
  24. package/iso/__tests__/fetch.spec.js +270 -0
  25. package/iso/csv-utils.js +206 -0
  26. package/iso/datetime.js +103 -0
  27. package/iso/fetch.js +129 -0
  28. package/iso/fetch2.js +180 -0
  29. package/iso/log-filter.js +17 -0
  30. package/iso/sleep.js +6 -0
  31. package/iso/ws.js +63 -0
  32. package/node/oss-files/oss-uploader-client-fetch.js +258 -0
  33. package/node/oss-files/oss-uploader-client-fetch.md +31 -0
  34. package/node/oss-files/oss-uploader-client.js +219 -0
  35. package/node/oss-files/oss-uploader-server.js +199 -0
  36. package/node/oss-files/oss-uploader-usage.js +121 -0
  37. package/node/oss-files/oss-uploader-usage.md +34 -0
  38. package/node/oss-files/s3-uploader-client.js +217 -0
  39. package/node/oss-files/s3-uploader-server.js +123 -0
  40. package/node/oss-files/s3-uploader-usage.js +77 -0
  41. package/node/oss-files/s3-uploader-usage.md +34 -0
  42. package/package.json +53 -0
  43. package/packageInfo.js +9 -0
  44. package/services/ali.js +279 -0
  45. package/services/aws.js +194 -0
  46. package/services/db/__tests__/keyv.spec.js +31 -0
  47. package/services/db/keyv.js +14 -0
  48. package/services/db/knex.js +67 -0
  49. package/services/db/redis.js +51 -0
  50. package/services/index.js +57 -0
  51. package/services/mq/README.md +8 -0
  52. package/services/websocket.js +139 -0
  53. package/t4t/README.md +1 -0
  54. package/traps.js +20 -0
  55. package/utils/__tests__/aes.spec.js +52 -0
  56. package/utils/aes.js +23 -0
  57. package/web/UI.md +71 -0
  58. package/web/bwc-autocomplete.js +211 -0
  59. package/web/bwc-combobox.js +343 -0
  60. package/web/bwc-fileupload.js +87 -0
  61. package/web/bwc-loading-overlay.js +54 -0
  62. package/web/bwc-t4t-form.js +511 -0
  63. package/web/bwc-table.js +756 -0
  64. package/web/fetch.js +129 -0
  65. package/web/i18n.js +24 -0
  66. package/web/idle.js +49 -0
  67. package/web/parse-jwt.js +15 -0
  68. package/web/pwa.js +84 -0
  69. package/web/sign-pad.js +164 -0
  70. package/web/t4t-fe.js +164 -0
  71. package/web/util.js +126 -0
  72. package/web/web-cam.js +182 -0
@@ -0,0 +1,39 @@
1
+ import { authFns, createToken, setTokensToHeader } from '../../../auth/index.js'
2
+
3
+ const { AUTH_ERROR_URL } = process.env
4
+ const OAUTH_OPTIONS = JSON.parse(process.env.OAUTH_OPTIONS || null) || {}
5
+ // set callback URL on github to <schema://host:port>/api/oauth/callback
6
+ // initiated from browser - window.location.replace('https://github.com/login/oauth/authorize?scope=user:email&client_id=XXXXXXXXXXXXXXXXXXXX')
7
+
8
+ // /callback
9
+ export const callbackOAuth = async (req, res) => {
10
+ try {
11
+ const { code, state } = req.query
12
+ const result = await fetch(OAUTH_OPTIONS.URL, {
13
+ method: 'POST',
14
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
15
+ body: JSON.stringify({ client_id: OAUTH_OPTIONS.CLIENT_ID, client_secret: OAUTH_OPTIONS.CLIENT_SECRET, code, state }),
16
+ })
17
+ const data = await result.json();
18
+ if (data.access_token) {
19
+ const resultUser = await fetch(OAUTH_OPTIONS.USER_URL, {
20
+ method: 'GET',
21
+ headers: { Authorization: `token ${data.access_token}`, },
22
+ })
23
+ const oauthUser = await resultUser.json();
24
+ const oauthId = oauthUser[OAUTH_OPTIONS.USER_ID] // github id, email
25
+
26
+ const user = await authFns.findUser({ [OAUTH_OPTIONS.FIND_ID]: oauthId }) // match github id (or email?) with our user in our application
27
+ if (!user) return res.status(401).json({ message: 'Unauthorized' })
28
+
29
+ const { id, groups } = user
30
+ const tokens = await createToken({ id, groups })
31
+ setTokensToHeader(res, tokens)
32
+ return res.redirect(OAUTH_OPTIONS.CALLBACK + '#' + tokens.access_token + ';' + tokens.refresh_token + ';' + JSON.stringify(tokens.user_meta)) // use url fragment...
33
+ }
34
+ return res.status(401).json({ message: 'Missing Token' })
35
+ } catch (e) {
36
+ console.error('github auth err', e)
37
+ return AUTH_ERROR_URL ? res.redirect(AUTH_ERROR_URL) : res.status(401).json({ error: 'NOT Authenticated' })
38
+ }
39
+ }
@@ -0,0 +1,87 @@
1
+ import jwt from 'jsonwebtoken'
2
+ import { createToken, setTokensToHeader } from '../../../auth/index.js'
3
+
4
+ const { AUTH_ERROR_URL } = process.env
5
+ const OIDC_OPTIONS = JSON.parse(process.env.OIDC_OPTIONS || null) || {}
6
+
7
+ const AUTH_URL = OIDC_OPTIONS ? `${OIDC_OPTIONS.URL}/auth?` : ''
8
+ const TOKEN_URL = OIDC_OPTIONS ? `${OIDC_OPTIONS.URL}/token` : ''
9
+
10
+ // payload.append('client_secret', OIDC_OPTIONS.CLIENT_SECRET) // if keycloak client access type is confidential
11
+
12
+ // TBD - verify endpoint for keycloak
13
+ // https://stackoverflow.com/questions/48274251/keycloak-access-token-validation-end-point
14
+ // curl --location --request POST 'https://HOST_KEYCLOAK/realms/master/protocol/openid-connect/token/introspect' \
15
+ // --header 'Content-Type: application/x-www-form-urlencoded' \
16
+ // --data-urlencode 'client_id=oficina-virtual' \
17
+ // --data-urlencode 'client_secret=4ZeE2v' \
18
+ // --data-urlencode
19
+
20
+ // GET /login
21
+ export const login = async (req, res) => {
22
+ // &scope=openid%20offline_access // get id token and refresh token
23
+ // &redirect_uri=ourApp%3A%2Fcallback
24
+ // &state=237c671a-29d7-11eb-adc1-0242ac120002
25
+ const payload = new URLSearchParams()
26
+ payload.append('response_type', 'code')
27
+ payload.append('client_id', OIDC_OPTIONS.CLIENT_ID)
28
+ if (OIDC_OPTIONS.CLIENT_SECRET) payload.append('client_secret', OIDC_OPTIONS.CLIENT_SECRET)
29
+ // payload.append('redirect_uri', 'http://127.0.0.1:3000/api/oidc/auth')
30
+ // payload.append('state', 'c45566104a3c7676c1cb92c33b19ab9bd91180c6')
31
+ res.redirect(AUTH_URL + payload.toString())
32
+ }
33
+
34
+ // GET /auth
35
+ // grant_type=authorization_code for now
36
+ export const auth = async (req, res) => { // callback
37
+ try {
38
+ const { code, session_state } = req.query
39
+ let payload = `grant_type=authorization_code&code=${code}`
40
+ + `&redirect_uri=${OIDC_OPTIONS.CALLBACK}&client_id=${OIDC_OPTIONS.CLIENT_ID}`
41
+ if (OIDC_OPTIONS.CLIENT_SECRET) payload += `&client_secret=${OIDC_OPTIONS.CLIENT_SECRET}`
42
+ // add offline_access to get refresh token
43
+ const result = await fetch(TOKEN_URL, {
44
+ method: 'POST',
45
+ headers: {
46
+ 'Accept': 'application/json',
47
+ 'Content-Type': 'application/x-www-form-urlencoded'
48
+ },
49
+ body: payload,
50
+ })
51
+ const data = await result.json();
52
+ let { access_token, refresh_token, ...user_meta } = data
53
+ if (OIDC_OPTIONS.REISSUE) {
54
+ const user = jwt.decode(access_token)
55
+ user.id = user[OIDC_OPTIONS.ID_NAME]
56
+ user.groups = user.resource_access.account.roles.join(',')
57
+ // TODO able to detect revoked?
58
+ const tokens = await createToken(user)
59
+ access_token = tokens.access_token
60
+ refresh_token = tokens.refresh_token
61
+ }
62
+ return res.redirect(OIDC_OPTIONS.CALLBACK + '#' + access_token + ';' + refresh_token + ';' + JSON.stringify(user_meta))
63
+ } catch (e) {
64
+ console.log(e)
65
+ return AUTH_ERROR_URL ? res.redirect(AUTH_ERROR_URL) : res.status(401).json({ error: 'NOT Authenticated' })
66
+ }
67
+ }
68
+
69
+ // GET /refresh
70
+ export const refresh = async (req, res) => {
71
+ const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }
72
+ const payload = new URLSearchParams()
73
+ payload.append('grant_type', 'refresh_token')
74
+ payload.append('refresh_token', req.cookies?.refresh_token || req.header('refresh_token') || req.query?.refresh_token)
75
+ payload.append('client_id', OIDC_OPTIONS.CLIENT_ID)
76
+ // add offline_access to get refresh token
77
+ const result = await fetch(TOKEN_URL, {
78
+ method: 'POST',
79
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', ...headers },
80
+ body: JSON.stringify(payload),
81
+ })
82
+ const data = await result.json();
83
+ const { access_token, refresh_token } = data
84
+ const tokens = { access_token, refresh_token }
85
+ setTokensToHeader(res, tokens)
86
+ res.json(tokens)
87
+ }
@@ -0,0 +1,100 @@
1
+ // own authentication
2
+ import jwt from 'jsonwebtoken'
3
+ import bcrypt from 'bcryptjs'
4
+ import * as otplib from 'otplib';
5
+ // import { authenticator } from 'otplib/authenticator';
6
+
7
+ import { getSecret, createToken, setTokensToHeader, authFns } from '../../../auth/index.js'
8
+ const {
9
+ COOKIE_HTTPONLY,
10
+ AUTH_USER_FIELD_LOGIN,
11
+ AUTH_USER_FIELD_PASSWORD,
12
+ AUTH_USER_FIELD_GAKEY,
13
+ AUTH_USER_FIELD_ID_FOR_JWT,
14
+ JWT_REFRESH_STORE,
15
+ USE_OTP,
16
+ JWT_ALG
17
+ } = process.env
18
+
19
+ const logout = async (req, res) => {
20
+ let id = null
21
+ try {
22
+ let access_token = null
23
+ let tmp = req.cookies?.Authorization || req.header('Authorization') || req.query?.Authorization
24
+ access_token = tmp.split(' ')[1]
25
+ const result = jwt.decode(access_token)
26
+ id = result.id
27
+ jwt.verify(access_token, getSecret('verify', 'access'), { algorithm: [JWT_ALG] }) // throw if expired or invalid
28
+ } catch (e) {
29
+ if (e.name !== 'TokenExpiredError') id = null
30
+ }
31
+ try {
32
+ if (id) {
33
+ await authFns.revokeRefreshToken(id) // clear
34
+ if (COOKIE_HTTPONLY) {
35
+ res.clearCookie('refresh_token')
36
+ res.clearCookie('Authorization')
37
+ }
38
+ return res.status(200).json({ message: 'Logged Out' })
39
+ }
40
+ } catch (e) { }
41
+ return res.status(500).json()
42
+ }
43
+
44
+ const refresh = async (req, res) => {
45
+ // refresh logic all done in authUser
46
+ return res.status(401).json({ message: 'Error token revoked' })
47
+ }
48
+
49
+
50
+ const login = async (req, res) => {
51
+ try {
52
+ const user = await authFns.findUser({
53
+ [AUTH_USER_FIELD_LOGIN]: req.body[AUTH_USER_FIELD_LOGIN]
54
+ })
55
+ const password = req.body[AUTH_USER_FIELD_PASSWORD]
56
+ if (!user) return res.status(401).json({ message: 'Incorrect credentials...' })
57
+ if (!bcrypt.compareSync(password, user[AUTH_USER_FIELD_PASSWORD])) return res.status(401).json({ message: 'Incorrect credentials' })
58
+ if (user.revoked) return res.status(401).json({ message: 'Revoked credentials' })
59
+ const id = user[AUTH_USER_FIELD_ID_FOR_JWT]
60
+ if (!id) return res.status(401).json({ message: 'Authorization Format Error' })
61
+ if (USE_OTP) {
62
+ // Currently supports only Google Authenticator
63
+ // Fido2 can be added in future
64
+ return res.status(200).json({ otp: id })
65
+ }
66
+ const tokens = await createToken(user) // 5 minute expire for login
67
+ setTokensToHeader(res, tokens)
68
+ return res.status(200).json(tokens)
69
+ } catch (e) {
70
+ console.log('login err', e.toString())
71
+ }
72
+ return res.status(500).json()
73
+ }
74
+
75
+ const otp = async (req, res) => { // need to be authentication, body { id: '', pin: '123456' }
76
+ try {
77
+ const { id, pin } = req.body
78
+ const user = await authFns.findUser({ id })
79
+ if (user) {
80
+ const gaKey = user[AUTH_USER_FIELD_GAKEY]
81
+ // if (USE_OTP !== 'TEST' ? otplib.authenticator.check(pin, gaKey) : String(pin) === '111111') { // NOTE: expiry will be determined by authenticator itself
82
+ if (USE_OTP !== 'TEST' ? authenticator.check(pin, gaKey) : String(pin) === '111111') { // NOTE: expiry will be determined by authenticator itself
83
+ const tokens = await createToken(user)
84
+ setTokensToHeader(res, tokens)
85
+ return res.status(200).json(tokens)
86
+ } else {
87
+ return res.status(401).json({ message: 'Error token wrong pin' })
88
+ }
89
+ }
90
+ } catch (e) { console.log('otp err', e.toString()) }
91
+ return res.status(401).json({ message: 'Error token revoked' })
92
+ }
93
+
94
+
95
+ export {
96
+ logout,
97
+ refresh,
98
+ login,
99
+ otp,
100
+ }
@@ -0,0 +1,74 @@
1
+ // working SAML ADFS example
2
+ // no refresh token, issue own OAuth2 like JWT server
3
+
4
+ import { SAML } from '@node-saml/node-saml'
5
+ import { createToken, setTokensToHeader } from '../../../auth/index.js'
6
+
7
+ const { SAML_OPTIONS, SAML_JWT_MAP, SAML_CERTIFICATE, SAML_PRIVATE_KEY, AUTH_ERROR_URL } = process.env
8
+ const samlJwtMap = JSON.parse(SAML_JWT_MAP || null)
9
+ const samlOptions = JSON.parse(SAML_OPTIONS || null)
10
+
11
+ if (samlOptions) {
12
+ if (SAML_CERTIFICATE) samlOptions.privateCert = SAML_CERTIFICATE
13
+ if (SAML_PRIVATE_KEY) {
14
+ samlOptions.privateKey = SAML_PRIVATE_KEY
15
+ samlOptions.decryptionPvk = SAML_PRIVATE_KEY
16
+ }
17
+ }
18
+
19
+ const saml = samlOptions ? new SAML(samlOptions) : null
20
+
21
+ // /login
22
+ export const login = async (req, res) => {
23
+ // return res.redirect('/' + token...) // for faking, bypass real callback
24
+ // console.debug(req.header('referer'), req.query.RelayState)
25
+ // getAuthorizeUrlAsync(RelayState: string, host: string | undefined, options: AuthOptions)
26
+ const authUrl = await saml?.getAuthorizeUrlAsync(req.query.RelayState) // validatePostResponseAsync (calls..., processValidlySignedPostRequestAsync)
27
+ res.redirect(authUrl);
28
+ }
29
+
30
+ // POST /callback
31
+ export const auth = async (req, res) => {
32
+ try {
33
+ const parsedResponse = await saml?.validatePostResponseAsync(req.body)
34
+
35
+ // TEST
36
+ // console.log(typeof parsedResponse, parsedResponse)
37
+ // res.json({ message: 'testing node-saml ok', parsedResponse })
38
+
39
+ // Callback
40
+ try {
41
+ const TO = req.body.RelayState
42
+ const authenticated = !parsedResponse.loggedOut
43
+ const user = {
44
+ id: parsedResponse.profile[samlJwtMap.id], // id: req.user.nameID, // string
45
+ groups: parsedResponse.profile[samlJwtMap.groups], // groups: req.user.Role, // comma seperated string or array or object...
46
+ }
47
+ if (!TO) {
48
+ // if no RelayState, then it is a test
49
+ return res.status(200).json({
50
+ authenticated,
51
+ user
52
+ })
53
+ }
54
+ // console.log(TO, user, authenticated, samlJwtMap, parsedResponse['Role'])
55
+ if (authenticated) {
56
+ const tokens = await createToken(user)
57
+ setTokensToHeader(res, tokens)
58
+ return res.redirect(TO + '#' + tokens.access_token + ';' + tokens.refresh_token + ';' + JSON.stringify(tokens.user_meta)) // use url fragment...
59
+ } else {
60
+ return AUTH_ERROR_URL ? res.redirect(AUTH_ERROR_URL) : res.status(401).json({ error: 'NOT Authenticated' })
61
+ }
62
+ } catch (e) {
63
+ // return AUTH_ERROR_URL ? res.redirect(AUTH_ERROR_URL) : res.status(500).json({ error: e.toString() })
64
+ }
65
+ } catch (e) {
66
+ console.log('SAML callback error', e)
67
+ res.json({
68
+ message: e.toString(),
69
+ note: 'Currently it always triggers invalid document signature fix is on the way'
70
+ })
71
+ }
72
+ res.send("ok");
73
+ }
74
+
@@ -0,0 +1,48 @@
1
+ // import path from 'path'
2
+ // path.extname('index.html')
3
+ // returns '.html'
4
+ // req.file / req.files[index]
5
+ // {
6
+ // fieldname: 'kycfile',
7
+ // originalname: 'todo.txt',
8
+ // encoding: '7bit',
9
+ // mimetype: 'text/plain',
10
+ // destination: 'uploads/',
11
+ // filename: 'kycfile-1582238409067',
12
+ // path: 'uploads\\kycfile-1582238409067',
13
+ // size: 110
14
+ // }
15
+
16
+ import multer from 'multer'
17
+
18
+ const memoryUpload = (options) => multer( Object.assign({
19
+ storage: multer.memoryStorage(),
20
+ limits: { files: 1, fileSize: 500000 }
21
+ }, options) )
22
+
23
+ // TBD
24
+
25
+ const storageUpload = ({ folder, options }) => {
26
+ // validate binary file type... using npm file-type?
27
+ // https://dev.to/ayanabilothman/file-type-validation-in-multer-is-not-safe-3h8l
28
+ // const fileFilter = (req, file, cb) => {
29
+ // if (['image/png', 'image/jpeg'].includes(file.mimetype)) {
30
+ // cb(null, true);
31
+ // } else {
32
+ // cb(new Error('Invalid file type!'), false)
33
+ // }
34
+ // }
35
+ return multer(Object.assign({
36
+ storage: multer.diskStorage({
37
+ // fileFilter
38
+ destination: (req, file, cb) => cb(null, folder),
39
+ filename: (req, file, cb) => cb(null, file.originalname) // file.fieldname, file.originalname
40
+ }),
41
+ limits: { files: 1, fileSize: 8000000 }
42
+ }, options))
43
+ }
44
+
45
+ export {
46
+ memoryUpload,
47
+ storageUpload,
48
+ }
package/index.js ADDED
@@ -0,0 +1 @@
1
+ // placeholder
package/iso/README.md ADDED
@@ -0,0 +1,4 @@
1
+ ## Isomorphic Javascript
2
+
3
+ contains Javascript code that is useful for both Frontend And Backend
4
+
@@ -0,0 +1,128 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import {
5
+ arrayToCSVRow,
6
+ jsonToCSVHeader,
7
+ jsonToCSVRow,
8
+ jsonToCsv,
9
+ parseAndValidateCsv,
10
+ csvToJson,
11
+ } from '../csv-utils.js';
12
+
13
+ describe.skip('csv-utils.js', () => {
14
+ describe('arrayToCSVRow', () => {
15
+ it('should convert array of primitives to CSV row', () => {
16
+ assert.strictEqual(arrayToCSVRow([1, 'a', true]), '1,a,true');
17
+ });
18
+
19
+ it('should escape commas, quotes, and newlines in strings', () => {
20
+ assert.strictEqual(arrayToCSVRow(['a,b', 'c"d', 'e\nf']), '"a,b","c""d","e\nf"');
21
+ });
22
+
23
+ it('should stringify and escape objects', () => {
24
+ assert.strictEqual(arrayToCSVRow([{ x: 1, y: 'z' }]), '"{""x"":1,""y"":""z""}"');
25
+ });
26
+
27
+ it('should handle null and undefined as empty fields', () => {
28
+ assert.strictEqual(arrayToCSVRow([null, undefined, 'foo']), ',,foo');
29
+ });
30
+ });
31
+
32
+ describe('jsonToCSVHeader', () => {
33
+ it('should convert object keys to CSV header', () => {
34
+ assert.strictEqual(jsonToCSVHeader({ a: 1, b: 2 }), 'a,b');
35
+ });
36
+ });
37
+
38
+ describe('jsonToCSVRow', () => {
39
+ it('should convert object values to CSV row', () => {
40
+ assert.strictEqual(jsonToCSVRow({ a: 1, b: 'foo' }), '1,foo');
41
+ });
42
+
43
+ it('should escape values as needed', () => {
44
+ assert.strictEqual(jsonToCSVRow({ a: 'a,b', b: 'c"d' }), '"a,b","c""d"');
45
+ });
46
+ });
47
+
48
+ describe('jsonToCsv', () => {
49
+ it('should convert array of objects to CSV', () => {
50
+ const arr = [
51
+ { a: 1, b: 'foo' },
52
+ { a: 2, b: 'bar' },
53
+ ];
54
+ assert.strictEqual(jsonToCsv(arr, ',', '\n', true), 'a,b\n1,foo\n2,bar\n');
55
+ });
56
+
57
+ it('should throw on column mismatch unless ignoreColumnMismatch is true', () => {
58
+ const arr = [
59
+ { a: 1, b: 'foo' },
60
+ { a: 2 },
61
+ ];
62
+ assert.throws(() => jsonToCsv(arr));
63
+ assert.doesNotThrow(() => jsonToCsv(arr, ',', '\n', true));
64
+ });
65
+
66
+ it('should not add missing columns when ignoreColumnMismatch is true', () => {
67
+ const arr = [
68
+ { a: 1, b: 'foo' },
69
+ { a: 2 },
70
+ ];
71
+ assert.strictEqual(jsonToCsv(arr, ',', '\n', true), 'a,b\n1,foo\n2\n');
72
+ });
73
+ });
74
+
75
+ describe('csvToJson', () => {
76
+ it('should convert CSV to JSON array of objects', () => {
77
+ const csv = 'a,b\n1,foo\n2,bar\n';
78
+ const result = csvToJson({ _text: csv });
79
+ assert.deepStrictEqual(result, [
80
+ { a: '1', b: 'foo' },
81
+ { a: '2', b: 'bar' }
82
+ ]);
83
+ });
84
+
85
+ it('should handle quoted fields with commas and newlines', () => {
86
+ const csv = 'a,b\n1,"hello, world"\n2,"line1\nline2"\n';
87
+ const result = csvToJson({ _text: csv });
88
+ assert.strictEqual(result[0].b, 'hello, world');
89
+ assert.strictEqual(result[1].b, 'line1\nline2');
90
+ });
91
+
92
+ it('should throw on column mismatch unless ignoreColumnMismatch is true', () => {
93
+ const csv = 'a,b\n1,2,3\n';
94
+ assert.throws(() => csvToJson({ _text: csv }));
95
+ assert.doesNotThrow(() => csvToJson({ _text: csv, ignoreColumnMismatch: true }));
96
+ });
97
+ });
98
+
99
+ describe('parseAndValidateCsv', () => {
100
+ it('should validate well-formed CSV', () => {
101
+ const csv = 'a,b\n1,2\n3,4\n';
102
+ const result = parseAndValidateCsv(csv);
103
+ assert.strictEqual(result.valid, true);
104
+ assert.deepStrictEqual(result.rows, [['a', 'b'], ['1', '2'], ['3', '4']]);
105
+ });
106
+
107
+ it('should detect unclosed quotes', () => {
108
+ const csv = 'a,b\n"1,2\n3,4\n';
109
+ const result = parseAndValidateCsv(csv);
110
+ assert.strictEqual(result.valid, false);
111
+ assert.match(result.reason, /Unclosed quoted field/);
112
+ });
113
+
114
+ it('should detect corrupted JSON in fields', () => {
115
+ const csv = 'a,b\n{"x":1},[1,2\n';
116
+ const result = parseAndValidateCsv(csv);
117
+ assert.strictEqual(result.valid, false);
118
+ assert.match(result.reason, /Corrupted JSON/);
119
+ });
120
+
121
+ it('should validate JSON data', () => {
122
+ const csv = 'a,b\n1,"{ ""aa"": ""123"" }"\n';
123
+ const result = parseAndValidateCsv(csv);
124
+ assert.strictEqual(result.valid, true);
125
+ assert.deepStrictEqual(result.rows, [['a', 'b'], ['1', '{ "aa": "123" }']]);
126
+ });
127
+ });
128
+ });
@@ -0,0 +1,101 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import {
4
+ dateStrAddDay,
5
+ dateStrAddDayISO,
6
+ getLocaleDateTimeTzISO,
7
+ getLocaleDateISO,
8
+ getLocaleTimeISO,
9
+ getTzOffsetISO,
10
+ getYmdhmsUtc,
11
+ getDayOfWeek
12
+ } from '../datetime.js';
13
+
14
+ describe.skip('datetime.js', () => {
15
+ describe('dateStrAddDay', () => {
16
+ it('should add days to ISO date string and return [YYYY, MM, DD]', () => {
17
+ const result = dateStrAddDay('2023-01-01T00:00:00Z', 2);
18
+ assert.deepStrictEqual(result, ['2023', '01', '03']);
19
+ });
20
+ it('should default to 0 days', () => {
21
+ const result = dateStrAddDay('2023-01-01T00:00:00Z');
22
+ assert.deepStrictEqual(result, ['2023', '01', '01']);
23
+ });
24
+ });
25
+
26
+ describe('dateStrAddDayISO', () => {
27
+ it('should add days and return ISO string', () => {
28
+ const result = dateStrAddDayISO('2023-01-01T00:00:00Z', 1);
29
+ assert.ok(result.startsWith('2023-01-02'));
30
+ });
31
+ it('should default to 0 days', () => {
32
+ const result = dateStrAddDayISO('2023-01-01T00:00:00Z');
33
+ assert.ok(result.startsWith('2023-01-01'));
34
+ });
35
+ });
36
+
37
+ describe('getLocaleDateTimeTzISO', () => {
38
+ it('should return formatted date-time with timezone', () => {
39
+ const dt = '2023-10-24T11:40:15Z';
40
+ const tz = 'Asia/Singapore';
41
+ const result = getLocaleDateTimeTzISO(dt, tz);
42
+ assert.match(result, /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} GMT\+8/);
43
+ });
44
+ });
45
+
46
+ describe('getLocaleDateISO', () => {
47
+ it('should return local date in ISO format', () => {
48
+ const dt = '2023-10-24T11:40:15Z';
49
+ const tz = 'Asia/Singapore';
50
+ const result = getLocaleDateISO(dt, tz);
51
+ assert.match(result, /\d{4}-\d{2}-\d{2}/);
52
+ });
53
+ });
54
+
55
+ describe('getLocaleTimeISO', () => {
56
+ it('should return local time in ISO format', () => {
57
+ const dt = '2023-10-24T11:40:15Z';
58
+ const tz = 'Asia/Singapore';
59
+ const result = getLocaleTimeISO(dt, tz);
60
+ assert.match(result, /\d{2}:\d{2}:\d{2}/);
61
+ });
62
+ });
63
+
64
+ describe('getTzOffsetISO', () => {
65
+ it('should return timezone offset in ISO format', () => {
66
+ const date = new Date('2023-01-01T00:00:00Z');
67
+ const result = getTzOffsetISO(date);
68
+ assert.match(result, /^[+-]\d{2}:\d{2}$/);
69
+ });
70
+ it('should default to current date', () => {
71
+ const result = getTzOffsetISO();
72
+ assert.match(result, /^[+-]\d{2}:\d{2}$/);
73
+ });
74
+ });
75
+
76
+ describe('getYmdhmsUtc', () => {
77
+ it('should return UTC timestamp in YYYYMMDD_HHmmssZ format', () => {
78
+ const date = new Date('2023-01-01T12:34:56Z');
79
+ const result = getYmdhmsUtc(date);
80
+ assert.strictEqual(result, '20230101_123456Z');
81
+ });
82
+ it('should default to current date', () => {
83
+ const result = getYmdhmsUtc();
84
+ assert.match(result, /\d{8}_\d{6}Z/);
85
+ });
86
+ });
87
+
88
+ describe('getDayOfWeek', () => {
89
+ it('should return correct day index for UTC', () => {
90
+ const date = '2023-01-01T00:00:00Z'; // Sunday
91
+ const result = getDayOfWeek(date);
92
+ assert.strictEqual(result, 0);
93
+ });
94
+ it('should return correct day index for timezone', () => {
95
+ const date = '2023-01-01T00:00:00Z';
96
+ const tz = 'Asia/Singapore';
97
+ const result = getDayOfWeek(date, tz);
98
+ assert.ok(result >= 0 && result <= 6);
99
+ });
100
+ });
101
+ });