• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

box / boxcli / 13208717992

07 Feb 2025 09:59PM UTC coverage: 84.603% (-0.7%) from 85.332%
13208717992

Pull #564

github

web-flow
Merge e5bbcd2b6 into 3d3b19f65
Pull Request #564: feat: add real API integration tests for users

1192 of 1607 branches covered (74.18%)

Branch coverage included in aggregate %.

4374 of 4972 relevant lines covered (87.97%)

374.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.64
/src/util.js
1
'use strict';
2

3
const _ = require('lodash');
9✔
4
const BoxCLIError = require('./cli-error');
9✔
5
const os = require('os');
9✔
6
const path = require('path');
9✔
7
const fs = require('fs');
9✔
8
const { mkdirp } = require('mkdirp');
9✔
9

10
const REQUIRED_CONFIG_VALUES = Object.freeze([
9✔
11
        'boxAppSettings.clientID',
12
        'boxAppSettings.clientSecret',
13
        'boxAppSettings.appAuth.publicKeyID',
14
        'boxAppSettings.appAuth.passphrase',
15
        'enterpriseID',
16
]);
17

18
const REQUIRED_CONFIG_VALUES_CCG = Object.freeze([
9✔
19
        'boxAppSettings.clientID',
20
        'boxAppSettings.clientSecret',
21
        'enterpriseID',
22
]);
23

24
const NUMBER_REGEX = /^[-+]?\d*\.?\d+$/u;
9✔
25
const UNESCAPED_COMMA_REGEX = /(?<![^\\](?:\\\\)*\\),/gu;
9✔
26
const UNESCAPED_SUBSCRIPT_REGEX =
27
        /(?<![^\\](?:\\\\)*\\)\[(.*?)(?<![^\\](?:\\\\)*\\)\]/gu;
9✔
28

29
const PATH_ESCAPES = Object.freeze({
9✔
30
        '/': '~1',
31
        '~': '~0',
32
});
33

34
/**
35
 * Unescape a string that no longer needs to be escaped with slashes,
36
 * optionally replacing certain escaped characters with new
37
 * escape codes.
38
 *
39
 * @param {string} str The string to unescape
40
 * @param {Object} replacements A mapping of escaped characters to new escape codes
41
 * @returns {string} The unescaped string
42
 * @private
43
 */
44
function unescapeString(str, replacements = {}) {
1,440✔
45
        let ret = '',
3,096✔
46
                chars = [...str];
3,096✔
47

48
        while (chars.length > 0) {
3,096✔
49
                let char = chars.shift();
21,708✔
50
                if (char === '\\') {
21,708✔
51
                        let nextChar = chars.shift();
18✔
52
                        nextChar = replacements[nextChar] || nextChar;
18!
53
                        if (nextChar) {
18!
54
                                ret += nextChar;
18✔
55
                        }
56
                } else {
57
                        ret += char;
21,690✔
58
                }
59
        }
60

61
        return ret;
3,096✔
62
}
63

64
/**
65
 * Parse a metadata key into the correct format, including escaping
66
 * for JSON Patch operations.
67
 *
68
 * This method accepts two formats:
69
 * 1) /path/to\/from/key => /path/to~1from/key
70
 * 2) path[to][] => /path/to/-
71
 *
72
 * @param {string} value The value to parse as a metadata key
73
 * @returns {string} The parsed key
74
 * @private
75
 */
76
function parseKey(value) {
77
        if (value.startsWith('/')) {
1,638✔
78
                // Treat as path
79
                return unescapeString(value, PATH_ESCAPES);
594✔
80
        }
81
        // Treat as key
82
        let parts = value.split(UNESCAPED_SUBSCRIPT_REGEX);
1,044✔
83
        if (parts[parts.length - 1] === '') {
1,044✔
84
                parts = parts.slice(0, -1);
90✔
85
        }
86
        return `/${parts
1,044✔
87
                .map((s) => (s ? unescapeString(s, PATH_ESCAPES) : '-'))
1,134✔
88
                .join('/')}`;
89
}
90

91
/**
92
 * Parses a metadata value from command-line input string
93
 * to actual value.
94
 *
95
 * This method supports the following formats:
96
 * 1) "#123.4" => 123.4
97
 * 2) "[foo,bar]" => [ "foo", "bar" ]
98
 * 3) Everything else => unescaped string
99
 *
100
 * @param {string} value The command-line value to parse
101
 * @returns {string|number|string[]} The parsed value
102
 * @private
103
 */
104
function parseValue(value) {
105
        if (value.startsWith('#')) {
1,242✔
106
                // Try parsing as number
107
                let valueStr = unescapeString(value.substr(1));
414✔
108
                if (valueStr.match(NUMBER_REGEX)) {
414!
109
                        let parsedValue = parseFloat(valueStr);
414✔
110
                        if (!Number.isNaN(parsedValue)) {
414!
111
                                return parsedValue;
414✔
112
                        }
113
                }
114

115
                // Parsing failed, fall back to string value
116
        } else if (value.startsWith('[') && value.endsWith(']')) {
828✔
117
                // Handle as array
118
                let interiorStr = value.substring(1, value.length - 1);
342✔
119

120
                if (interiorStr.length === 0) {
342✔
121
                        return [];
72✔
122
                }
123

124
                return interiorStr
270✔
125
                        .split(UNESCAPED_COMMA_REGEX)
126
                        .map((el) => unescapeString(el));
540✔
127
        }
128

129
        // No other parsing applied; treat as string
130
        return unescapeString(value);
486✔
131
}
132

133
/**
134
 * Parses a metadata command line string into the correct operation
135
 * object.
136
 *
137
 * This method performs the following transformations:
138
 * 1) key=value => {path: '/key', value: 'value'}
139
 * 2) key1>key2 => {from: '/key1', path: '/key2'}
140
 *
141
 * @param {string} input The input string from the command line
142
 * @returns {Object} The parsed metadata operation object
143
 * @private
144
 */
145
function parseMetadataString(input) {
146
        let chars = [...input];
1,476✔
147
        let op = {};
1,476✔
148

149
        // Find the splitting point, if one exists
150
        let splitIndex = chars.findIndex((char, index, arr) => {
1,476✔
151
                if (char === '>' || char === '=') {
14,904✔
152
                        let escaped = false;
1,404✔
153
                        for (let i = index - 1; i >= 0; i--) {
1,404✔
154
                                if (arr[i] === '\\') {
1,404!
155
                                        escaped = !escaped;
×
156
                                } else {
157
                                        break;
1,404✔
158
                                }
159
                        }
160

161
                        if (!escaped) {
1,404!
162
                                return true;
1,404✔
163
                        }
164
                }
165

166
                return false;
13,500✔
167
        });
168

169
        if (splitIndex === -1) {
1,476✔
170
                // This is just a key, return it
171
                op.path = parseKey(chars.join(''));
72✔
172
                return op;
72✔
173
        }
174

175
        let separator = chars[splitIndex];
1,404✔
176
        if (separator === '>') {
1,404✔
177
                op.from = parseKey(chars.slice(0, splitIndex).join(''));
162✔
178
                op.path = parseKey(chars.slice(splitIndex + 1).join(''));
162✔
179
        } else {
180
                op.path = parseKey(chars.slice(0, splitIndex).join(''));
1,242✔
181
                op.value = parseValue(chars.slice(splitIndex + 1).join(''));
1,242✔
182
        }
183

184
        return op;
1,404✔
185
}
186

187
/**
188
 * Parse a string into a JSON object
189
 *
190
 * @param {string} inputString The string to parse
191
 * @param {string[]} keys The keys to parse from the string
192
 * @returns {Object} The parsed object
193
 */
194
function parseStringToObject(inputString, keys) {
195
        const result = {};
108✔
196

197
        while (inputString.length > 0) {
108✔
198
                inputString = inputString.trim();
324✔
199
                let parsedKey = inputString.split('=')[0];
324✔
200
                inputString = inputString.substring(inputString.indexOf('=') + 1);
324✔
201

202
                // Find the next key or the end of the string
203
                let nextKeyIndex = inputString.length;
324✔
204
                for (let key of keys) {
324✔
205
                        let keyIndex = inputString.indexOf(key);
972✔
206
                        if (keyIndex !== -1 && keyIndex < nextKeyIndex) {
972✔
207
                                nextKeyIndex = keyIndex;
216✔
208
                        }
209
                }
210

211
                let parsedValue = inputString.substring(0, nextKeyIndex).trim();
324✔
212
                if (parsedValue.endsWith(',') && nextKeyIndex !== inputString.length) {
324✔
213
                        parsedValue = parsedValue.substring(0, parsedValue.length - 1);
216✔
214
                }
215
                if (parsedValue.startsWith('"') && parsedValue.endsWith('"')) {
324!
216
                        parsedValue = parsedValue.substring(1, parsedValue.length - 1);
×
217
                }
218

219
                if (!keys.includes(parsedKey)) {
324!
220
                        throw new BoxCLIError(
×
221
                                `Invalid key '${parsedKey}'. Valid keys are ${keys.join(', ')}`
222
                        );
223
                }
224

225
                result[parsedKey] = parsedValue;
324✔
226
                inputString = inputString.substring(nextKeyIndex);
324✔
227
        }
228
        return result;
108✔
229
}
230

231
/**
232
 * Check if directory exists and creates it if shouldCreate flag was passed.
233
 *
234
 * @param {string} dirPath Directory path to check and create
235
 * @param {boolean} shouldCreate Flag indicating if the directory should be created
236
 * @returns {Promise<void>} empty promise
237
 * @throws BoxCLIError
238
 */
239
async function checkDir(dirPath, shouldCreate) {
240
        /* eslint-disable no-sync */
241
        if (!fs.existsSync(dirPath)) {
180✔
242
                if (shouldCreate) {
99✔
243
                        await mkdirp(dirPath);
90✔
244
                } else {
245
                        throw new BoxCLIError(
9✔
246
                                `The ${dirPath} path does not exist. Either create it, or pass the --create-path flag set to true`
247
                        );
248
                }
249
        }
250
}
251

252
/* eslint-disable require-jsdoc, require-await, no-shadow, promise/avoid-new, promise/prefer-await-to-callbacks */
253

254
async function readFileAsync(path, options) {
255
        return new Promise((resolve, reject) => {
×
256
                fs.readFile(path, options || {}, (err, result) => {
×
257
                        if (err) {
×
258
                                return reject(err);
×
259
                        }
260
                        return resolve(result);
×
261
                });
262
        });
263
}
264

265
async function writeFileAsync(file, data, options) {
266
        return new Promise((resolve, reject) => {
×
267
                fs.writeFile(file, data, options || {}, (err, result) => {
×
268
                        if (err) {
×
269
                                return reject(err);
×
270
                        }
271
                        return resolve(result);
×
272
                });
273
        });
274
}
275

276
async function readdirAsync(path, options) {
277
        return new Promise((resolve, reject) => {
36✔
278
                fs.readdir(path, options || {}, (err, result) => {
36✔
279
                        if (err) {
36!
280
                                return reject(err);
×
281
                        }
282
                        return resolve(result);
36✔
283
                });
284
        });
285
}
286

287
async function unlinkAsync(path) {
288
        return new Promise((resolve, reject) => {
×
289
                fs.unlink(path, (err, result) => {
×
290
                        if (err) {
×
291
                                return reject(err);
×
292
                        }
293
                        return resolve(result);
×
294
                });
295
        });
296
}
297

298
/* eslint-enable require-jsdoc, require-await, no-shadow, promise/avoid-new, promise/prefer-await-to-callbacks */
299

300
module.exports = {
9✔
301
        /**
302
         * Validates the a configuration object has all required properties
303
         * @param {Object} configObj The config object to validate
304
         * @param {boolean} isCCG Whether the config object is used for CCG auth
305
         * @returns {void}
306
         * @throws BoxCLIError
307
         */
308
        validateConfigObject(configObj, isCCG) {
309
                let checkProp = _.propertyOf(configObj);
×
310
                let requiredConfigValues = isCCG
×
311
                        ? REQUIRED_CONFIG_VALUES_CCG
312
                        : REQUIRED_CONFIG_VALUES;
313
                let missingProp = requiredConfigValues.find((key) => !checkProp(key));
×
314

315
                if (missingProp) {
×
316
                        throw new BoxCLIError(`Config object missing key ${missingProp}`);
×
317
                }
318
        },
319

320
        /**
321
         * Parses a path argument or flag value into a full absolute path
322
         *
323
         * @param {string} value The raw path string from the command line flag or arg
324
         * @returns {string} The resolved absolute path
325
         */
326
        parsePath(value) {
327
                // Check for homedir and expand if necessary
328
                // @NOTE: This can occur when the user passes a path via --flag="~/my-stuff" syntax, since the shell
329
                // doesn't get a chance to expand the tilde
330
                value = value.replace(
828✔
331
                        /^~(\/|\\|$)/u,
332
                        (match, ending) => os.homedir() + ending
×
333
                );
334
                return path.resolve(value);
828✔
335
        },
336

337
        /**
338
         * Unescape slashes from the given string.
339
         *
340
         * @param {string} value The raw string which can contains escaped slashes
341
         * @returns {string} A string with unescaped escaping in newline and tab characters.
342
         */
343
        unescapeSlashes(value) {
344
                try {
351✔
345
                        return JSON.parse(`"${value}"`);
351✔
346
                } catch (e) {
347
                        return value;
18✔
348
                }
349
        },
350

351
        /**
352
         * Parses the key=val string format for metadata into an object {key: val}
353
         *
354
         * @param {string} value The string containing metadata key and value
355
         * @returns {Object} The parsed metadata key and value
356
         */
357
        parseMetadata(value) {
358
                let op = parseMetadataString(value);
756✔
359
                if (!op.hasOwnProperty('path') || !op.hasOwnProperty('value')) {
756!
360
                        throw new BoxCLIError('Metadata must be in the form key=value');
×
361
                }
362

363
                let pathSegments = op.path.slice(1).split('/');
756✔
364
                if (pathSegments.length !== 1) {
756!
365
                        throw new BoxCLIError(
×
366
                                `Metadata value must be assigned to a top-level key, instead got ${op.path}`
367
                        );
368
                }
369

370
                let key = pathSegments[0];
756✔
371

372
                return { [key]: op.value };
756✔
373
        },
374

375
        /**
376
         * Parses strings like key=value or key1>key2 into objects
377
         * used to help define JSON patch operations.
378
         *
379
         * key=value => {path: key, value: value}
380
         * key1>key2 => {from: key1, path: key2}
381
         * other => {path: other}
382
         *
383
         * @param {string} value The input string
384
         * @returns {Object} The parsed operation object
385
         */
386
        parseMetadataOp(value) {
387
                return parseMetadataString(value);
720✔
388
        },
389
        parseStringToObject,
390
        checkDir,
391
        readFileAsync,
392
        writeFileAsync,
393
        readdirAsync,
394
        unlinkAsync,
395
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc