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

opengovsg / FormSG / 8464128558

28 Mar 2024 07:56AM CUT coverage: 73.678% (+0.009%) from 73.669%
8464128558

push

github

web-flow
refactor: move override code closer to other overrides (#7216)

* refactor: move override code closer to other overrides

* test: update test cases

2226 of 3707 branches covered (60.05%)

Branch coverage included in aggregate %.

8979 of 11501 relevant lines covered (78.07%)

38.68 hits per line

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

66.91
/src/app/modules/form/admin-form/admin-form.utils.ts
1
import { AxiosError } from 'axios'
2
import { StatusCodes } from 'http-status-codes'
51✔
3
import { err, ok, Result } from 'neverthrow'
51✔
4
import { v4 as uuidv4 } from 'uuid'
51✔
5

6
import {
51✔
7
  BasicField,
8
  DuplicateFormBodyDto,
9
  FieldUpdateDto,
10
  FormResponseMode,
11
  FormStatus,
12
  TextValidationOptions,
13
} from '../../../../../shared/types'
14
import {
51✔
15
  reorder,
16
  replaceAt,
17
} from '../../../../../shared/utils/immutable-array-fns'
18
import { EditFieldActions } from '../../../../shared/constants'
51✔
19
import { FormFieldSchema, IPopulatedForm, IUserSchema } from '../../../../types'
20
import { EditFormFieldParams } from '../../../../types/api'
21
import config from '../../../config/config'
51✔
22
import { createLoggerWithLabel } from '../../../config/logger'
51✔
23
import { CreatePresignedPostError } from '../../../utils/aws-s3'
51✔
24
import { isPossibleEmailFieldSchema } from '../../../utils/field-validation/field-validation.guards'
51✔
25
import {
51✔
26
  ApplicationError,
27
  DatabaseConflictError,
28
  DatabaseError,
29
  DatabasePayloadSizeError,
30
  DatabaseValidationError,
31
  MalformedParametersError,
32
  SecretsManagerConflictError,
33
  SecretsManagerError,
34
  SecretsManagerNotFoundError,
35
  TwilioCacheError,
36
} from '../../core/core.errors'
37
import { ErrorResponseData } from '../../core/core.types'
38
import { InvalidPaymentAmountError } from '../../payments/payments.errors'
51✔
39
import { StripeAccountError } from '../../payments/stripe.errors'
51✔
40
import { ResponseModeError } from '../../submission/submission.errors'
51✔
41
import { MissingUserError } from '../../user/user.errors'
51✔
42
import { SmsLimitExceededError } from '../../verification/verification.errors'
51✔
43
import {
51✔
44
  ForbiddenFormError,
45
  FormDeletedError,
46
  FormNotFoundError,
47
  LogicNotFoundError,
48
  PrivateFormError,
49
  TransferOwnershipError,
50
} from '../form.errors'
51
import { UNICODE_ESCAPED_REGEX } from '../form.utils'
51✔
52

53
import {
51✔
54
  EditFieldError,
55
  FieldNotFoundError,
56
  GoGovAlreadyExistError,
57
  GoGovBadGatewayError,
58
  GoGovError,
59
  GoGovRequestLimitError,
60
  GoGovServerError,
61
  GoGovValidationError,
62
  InvalidCollaboratorError,
63
  InvalidFileTypeError,
64
  PaymentChannelNotFoundError,
65
} from './admin-form.errors'
66
import {
51✔
67
  AssertFormFn,
68
  EditFormFieldResult,
69
  OverrideProps,
70
  PermissionLevel,
71
} from './admin-form.types'
72

73
const logger = createLoggerWithLabel(module)
51✔
74

75
/**
76
 * Handler to map ApplicationErrors to their correct status code and error
77
 * messages.
78
 * @param error The error to retrieve the status codes and error messages
79
 * @param coreErrorMessage Any error message to return instead of the default core error message, if any
80
 */
81
export const mapRouteError = (
51✔
82
  error: ApplicationError,
83
  coreErrorMessage?: string,
84
): ErrorResponseData => {
85
  switch (error.constructor) {
342!
86
    case SmsLimitExceededError:
87
      return {
3✔
88
        statusCode: StatusCodes.CONFLICT,
89
        errorMessage: error.message,
90
      }
91
    case InvalidFileTypeError:
92
    case CreatePresignedPostError:
93
      return {
6✔
94
        statusCode: StatusCodes.BAD_REQUEST,
95
        errorMessage: error.message,
96
      }
97
    case FormNotFoundError:
98
    case FieldNotFoundError:
99
    case LogicNotFoundError:
100
      return {
66✔
101
        statusCode: StatusCodes.NOT_FOUND,
102
        errorMessage: error.message,
103
      }
104
    case FormDeletedError:
105
      return {
44✔
106
        statusCode: StatusCodes.GONE,
107
        errorMessage: error.message,
108
      }
109
    case PrivateFormError:
110
    case ForbiddenFormError:
111
      return {
52✔
112
        statusCode: StatusCodes.FORBIDDEN,
113
        errorMessage: error.message,
114
      }
115
    case EditFieldError:
116
    case DatabaseValidationError:
117
    case MissingUserError:
118
    case InvalidCollaboratorError:
119
      return {
71✔
120
        statusCode: StatusCodes.UNPROCESSABLE_ENTITY,
121
        errorMessage: error.message,
122
      }
123
    case TransferOwnershipError:
124
      return {
7✔
125
        statusCode: StatusCodes.BAD_REQUEST,
126
        errorMessage: error.message,
127
      }
128
    case MalformedParametersError:
129
      return {
×
130
        statusCode: StatusCodes.BAD_REQUEST,
131
        errorMessage: error.message,
132
      }
133
    case DatabaseConflictError:
134
      return {
4✔
135
        statusCode: StatusCodes.CONFLICT,
136
        errorMessage: error.message,
137
      }
138
    case DatabasePayloadSizeError:
139
      return {
9✔
140
        statusCode: StatusCodes.REQUEST_TOO_LONG,
141
        errorMessage: error.message,
142
      }
143
    case DatabaseError:
144
      return {
78✔
145
        statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
146
        errorMessage: coreErrorMessage ?? error.message,
234!
147
      }
148
    case SecretsManagerNotFoundError:
149
      return {
×
150
        statusCode: StatusCodes.NOT_FOUND,
151
        errorMessage: coreErrorMessage ?? error.message,
×
152
      }
153
    case SecretsManagerConflictError:
154
      return {
×
155
        statusCode: StatusCodes.CONFLICT,
156
        errorMessage: coreErrorMessage ?? error.message,
×
157
      }
158
    case SecretsManagerError:
159
      return {
2✔
160
        statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
161
        errorMessage: coreErrorMessage ?? error.message,
6!
162
      }
163
    case TwilioCacheError:
164
      return {
×
165
        statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
166
        errorMessage: coreErrorMessage ?? error.message,
×
167
      }
168
    case StripeAccountError:
169
      return {
×
170
        statusCode: StatusCodes.BAD_GATEWAY,
171
        errorMessage: coreErrorMessage ?? error.message,
×
172
      }
173
    case InvalidPaymentAmountError:
174
      return {
×
175
        statusCode: StatusCodes.BAD_REQUEST,
176
        errorMessage: error.message,
177
      }
178
    case ResponseModeError:
179
      return {
×
180
        statusCode: StatusCodes.UNPROCESSABLE_ENTITY,
181
        errorMessage: error.message,
182
      }
183
    case PaymentChannelNotFoundError:
184
      return {
×
185
        statusCode: StatusCodes.FORBIDDEN,
186
        errorMessage: error.message,
187
      }
188
    case GoGovAlreadyExistError:
189
    case GoGovValidationError:
190
      return {
×
191
        statusCode: StatusCodes.BAD_REQUEST,
192
        errorMessage: error.message,
193
      }
194
    case GoGovRequestLimitError:
195
      return {
×
196
        statusCode: StatusCodes.TOO_MANY_REQUESTS,
197
        errorMessage: error.message,
198
      }
199
    case GoGovBadGatewayError:
200
      return {
×
201
        statusCode: StatusCodes.BAD_GATEWAY,
202
        errorMessage: error.message,
203
      }
204
    case GoGovError:
205
    case GoGovServerError:
206
      logger.error({
×
207
        message: 'GoGov server error observed',
208
        meta: {
209
          action: 'mapRouteError',
210
        },
211
        error,
212
      })
213
      return {
×
214
        statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
215
        errorMessage: error.message,
216
      }
217
    default:
218
      logger.error({
×
219
        message: 'Unknown route error observed',
220
        meta: {
221
          action: 'mapRouteError',
222
        },
223
        error,
224
      })
225

226
      return {
×
227
        statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
228
        errorMessage: 'Something went wrong. Please try again.',
229
      }
230
  }
231
}
232

233
/**
234
 * Asserts whether a form is available for retrieval by users.
235
 * @param form the form to check
236
 * @returns ok(true) if form status is not archived.
237
 * @returns err(FormDeletedError) if the form has already been archived
238
 */
239
export const assertFormAvailable = (
51✔
240
  form: IPopulatedForm,
241
): Result<true, FormDeletedError> => {
242
  return form.status === FormStatus.Archived
137✔
243
    ? err(new FormDeletedError('Form has been archived'))
244
    : ok(true)
245
}
246

247
/**
248
 * Asserts that the given user has read access for the form.
249
 * @returns ok(true) if given user has read permissions
250
 * @returns err(ForbiddenFormError) if user does not have read permissions
251
 */
252
export const assertHasReadPermissions: AssertFormFn = (user, form) => {
51✔
253
  // Is form admin. Automatically has permissions.
254
  if (String(user._id) === String(form.admin._id)) {
66✔
255
    return ok(true)
50✔
256
  }
257

258
  // Check if user email is currently in form's allowed list.
259
  const hasReadPermissions = !!form.permissionList?.find(
16!
260
    (allowedUser) =>
261
      allowedUser.email.toLowerCase() === user.email.toLowerCase(),
4✔
262
  )
263

264
  return hasReadPermissions
16✔
265
    ? ok(true)
266
    : err(
267
        new ForbiddenFormError(
268
          `User ${user.email} not authorized to perform read operation on Form ${form._id} with title: ${form.title}.`,
269
        ),
270
      )
271
}
272

273
/**
274
 * Asserts that the given user has delete permissions for the form.
275
 * @returns ok(true) if given user has delete permissions
276
 * @returns err(ForbiddenFormError) if user does not have delete permissions
277
 */
278
export const assertHasDeletePermissions: AssertFormFn = (user, form) => {
51✔
279
  const isFormAdmin = String(user._id) === String(form.admin._id)
13✔
280
  // If form admin
281
  return isFormAdmin
13✔
282
    ? ok(true)
283
    : err(
284
        new ForbiddenFormError(
285
          `User ${user.email} not authorized to perform delete operation on Form ${form._id} with title: ${form.title}.`,
286
        ),
287
      )
288
}
289

290
/**
291
 * Asserts that the given user has write permissions for the form.
292
 * @returns ok(true) if given user has write permissions
293
 * @returns err(ForbiddenFormError) if user does not have write permissions
294
 */
295
export const assertHasWritePermissions: AssertFormFn = (user, form) => {
51✔
296
  // Is form admin. Automatically has permissions.
297
  if (String(user._id) === String(form.admin._id)) {
48✔
298
    return ok(true)
38✔
299
  }
300

301
  // Check if user email is currently in form's allowed list, and has write
302
  // permissions.
303
  const hasWritePermissions = !!form.permissionList?.find(
10!
304
    (allowedUser) =>
305
      allowedUser.email.toLowerCase() === user.email.toLowerCase() &&
4✔
306
      allowedUser.write,
307
  )
308

309
  return hasWritePermissions
10✔
310
    ? ok(true)
311
    : err(
312
        new ForbiddenFormError(
313
          `User ${user.email} not authorized to perform write operation on Form ${form._id} with title: ${form.title}.`,
314
        ),
315
      )
316
}
317

318
export const getAssertPermissionFn = (level: PermissionLevel): AssertFormFn => {
51✔
319
  switch (level) {
117✔
320
    case PermissionLevel.Read:
321
      return assertHasReadPermissions
63✔
322
    case PermissionLevel.Write:
323
      return assertHasWritePermissions
44✔
324
    case PermissionLevel.Delete:
325
      return assertHasDeletePermissions
10✔
326
  }
327
}
328

329
/**
330
 * Reshapes given duplicate params into override props.
331
 * @param params the parameters to reshape
332
 * @param newAdminId the new admin id to inject
333
 *
334
 * @returns override props for use in duplicating a form
335
 */
336
export const processDuplicateOverrideProps = (
51✔
337
  params: DuplicateFormBodyDto,
338
  newAdminId: string,
339
): OverrideProps => {
340
  const { responseMode, title } = params
4✔
341

342
  const overrideProps: OverrideProps = {
4✔
343
    responseMode,
344
    title,
345
    admin: newAdminId,
346
  }
347

348
  switch (params.responseMode) {
4✔
349
    case FormResponseMode.Encrypt:
350
    case FormResponseMode.Multirespondent:
351
      overrideProps.publicKey = params.publicKey
2✔
352
      overrideProps.submissionLimit = null
2✔
353
      break
2✔
354
    case FormResponseMode.Email:
355
      overrideProps.emails = params.emails
2✔
356
      break
2✔
357
  }
358

359
  return overrideProps
4✔
360
}
361

362
/**
363
 * Private utility to update given field in the existing form fields.
364
 * @param existingFormFields the existing form fields
365
 * @param fieldToUpdate the field to replace the current field in existing form fields
366
 * @returns ok(new array with updated field) if fieldToUpdate can be found in the current fields
367
 * @returns err(EditFieldError) if field to be updated does not exist
368
 */
369
const updateCurrentField = (
51✔
370
  existingFormFields: FormFieldSchema[],
371
  fieldToUpdate: FormFieldSchema,
372
): EditFormFieldResult => {
373
  const existingFieldPosition = existingFormFields.findIndex(
3✔
374
    (f) => f.globalId === fieldToUpdate.globalId,
5✔
375
  )
376

377
  return existingFieldPosition === -1
3✔
378
    ? err(new EditFieldError('Field to be updated does not exist'))
379
    : ok(replaceAt(existingFormFields, existingFieldPosition, fieldToUpdate))
380
}
381

382
/**
383
 * Private utility to insert given field in the existing form fields.
384
 * @param existingFormFields the existing form fields
385
 * @param fieldToInsert the field to insert into the back of current fields
386
 * @returns ok(new array with field inserted) if fieldToInsert does not already exist
387
 * @returns err(EditFieldError) if field to be inserted already exists in current fields
388
 */
389
const insertField = (
51✔
390
  existingFormFields: FormFieldSchema[],
391
  fieldToInsert: FormFieldSchema,
392
): EditFormFieldResult => {
393
  const doesFieldExist = existingFormFields.some(
5✔
394
    (f) => f.globalId === fieldToInsert.globalId,
12✔
395
  )
396

397
  return doesFieldExist
5✔
398
    ? err(
399
        new EditFieldError(
400
          `Field ${fieldToInsert.globalId} to be inserted already exists`,
401
        ),
402
      )
403
    : ok([...existingFormFields, fieldToInsert])
404
}
405

406
/**
407
 * Private utility to delete given field in the existing form fields.
408
 * @param existingFormFields the existing form fields
409
 * @param fieldToDelete the field to be deleted that exists in the current field
410
 * @returns ok(new array with given field deleted) if fieldToDelete can be found in the current fields
411
 * @returns err(EditFieldError) if field to be deleted does not exist
412
 */
413
const deleteField = (
51✔
414
  existingFormFields: FormFieldSchema[],
415
  fieldToDelete: FormFieldSchema,
416
): EditFormFieldResult => {
417
  const updatedFormFields = existingFormFields.filter(
2✔
418
    (f) => f.globalId !== fieldToDelete.globalId,
6✔
419
  )
420

421
  return updatedFormFields.length === existingFormFields.length
2✔
422
    ? err(new EditFieldError('Field to be deleted does not exist'))
423
    : ok(updatedFormFields)
424
}
425

426
/**
427
 * Private utility to reorder the given field to the given newPosition in the existing form fields.
428
 *
429
 * @param existingFormFields the existing form fields
430
 * @param fieldToReorder the field to reorder in the existing form fields
431
 * @param newPosition the new index position to move the field to.
432
 * @returns ok(new array with updated field) if fieldToReorder can be found in the current fields
433
 * @returns err(EditFieldError) if field to reorder does not exist
434
 */
435
const reorderField = (
51✔
436
  existingFormFields: FormFieldSchema[],
437
  fieldToReorder: FormFieldSchema,
438
  newPosition: number,
439
): EditFormFieldResult => {
440
  const existingFieldPosition = existingFormFields.findIndex(
2✔
441
    (f) => f.globalId === fieldToReorder.globalId,
4✔
442
  )
443

444
  return existingFieldPosition === -1
2✔
445
    ? err(new EditFieldError('Field to be reordered does not exist'))
446
    : ok(reorder(existingFormFields, existingFieldPosition, newPosition))
447
}
448

449
/**
450
 * Utility factory to run correct update function depending on given action.
451
 * @param currentFormFields the existing form fields to update
452
 * @param editFieldParams the parameters with the given update to perform and any metadata required.
453
 *
454
 * @returns ok(updated form fields array) if fields update successfully
455
 * @returns err(EditFieldError) if any errors occur whilst updating fields
456
 */
457
export const getUpdatedFormFields = (
51✔
458
  currentFormFields: FormFieldSchema[],
459
  editFieldParams: EditFormFieldParams,
460
): EditFormFieldResult => {
461
  const { field: fieldToUpdate, action } = editFieldParams
12✔
462

463
  // TODO(#1210): Remove this function when no longer being called.
464
  // Sync states for backwards compatibility with old clients send inconsistent
465
  // email fields
466
  if (isPossibleEmailFieldSchema(fieldToUpdate)) {
12✔
467
    if (fieldToUpdate.hasAllowedEmailDomains === false) {
3✔
468
      fieldToUpdate.allowedEmailDomains = []
1✔
469
    } else {
470
      fieldToUpdate.hasAllowedEmailDomains = fieldToUpdate.allowedEmailDomains
2!
471
        ?.length
472
        ? fieldToUpdate.allowedEmailDomains.length > 0
473
        : false
474
    }
475
  }
476

477
  switch (action.name) {
12✔
478
    // Duplicate is just an alias of create for the use case.
479
    case EditFieldActions.Create:
480
    case EditFieldActions.Duplicate:
481
      return insertField(currentFormFields, fieldToUpdate)
5✔
482
    case EditFieldActions.Delete:
483
      return deleteField(currentFormFields, fieldToUpdate)
2✔
484
    case EditFieldActions.Reorder:
485
      return reorderField(currentFormFields, fieldToUpdate, action.position)
2✔
486
    case EditFieldActions.Update:
487
      return updateCurrentField(currentFormFields, fieldToUpdate)
3✔
488
  }
489
}
490

491
/**
492
 * Returns a msgSrvcName that will be used as the key to store secrets
493
 * under AWS Secrets Manager
494
 *
495
 * @param formId in which the secrets belong to
496
 * @returns string representing the msgSrvcName
497
 */
498
export const generateTwilioCredSecretKeyName = (formId: string): string =>
51✔
499
  `formsg/${config.secretEnv}/api/form/${formId}/twilio/${uuidv4()}`
3✔
500

501
/**
502
 * Returns boolean indicating if the key to store the secret in AWS Secrets Manager
503
 * was generated by the API
504
 *
505
 * @param msgSrvcName to check
506
 * @returns boolean indicating whether it was generated by the API
507
 */
508
export const checkIsApiSecretKeyName = (msgSrvcName: string): boolean => {
51✔
509
  const prefix = `formsg/${config.secretEnv}/api`
1✔
510
  return msgSrvcName.startsWith(prefix)
1✔
511
}
512

513
/**
514
 * Validation check for invalid utf-8 encoded unicode-escaped characteres
515
 * @param value
516
 * @param helpers
517
 * @returns custom err message if there are invalid characters in the input
518
 */
519
export const verifyValidUnicodeString = (value: any, helpers: any) => {
51✔
520
  // If there are invalid utf-8 encoded unicode-escaped characters,
521
  // nodejs treats the sequence of characters as a string e.g. \udbbb is treated as a 6-character string instead of an escaped unicode sequence
522
  // If this is saved into the db, an error is thrown when the driver attempts to read the db document as the driver interprets this as an escaped unicode sequence.
523
  // Since valid unicode-escaped characters will be processed correctly (e.g. \u00ae is processed as ®), they will not trigger an error
524
  // Also note that if the user intends to input a 6-character string of the same form e.g. \udbbb, the backslash will be escaped (i.e. double backslash) and hence this will also not trigger an error
525

526
  const valueStr = JSON.stringify(value)
26✔
527

528
  if (UNICODE_ESCAPED_REGEX.test(valueStr)) {
26✔
529
    return helpers.message({
2✔
530
      custom: 'There are invalid characters in your input',
531
    })
532
  }
533
  return value
24✔
534
}
535

536
/**
537
 * Checks if the user has the specified beta flag enabled
538
 * @param user
539
 * @param flag which is the string representation of the beta flag
540
 * @returns ok(user) if the user has the beta flag enabled
541
 * @returns err(ForbiddenFormUser) to deny user access to the beta feature
542
 */
543
export const verifyUserBetaflag = (
51✔
544
  user: IUserSchema,
545
  betaFlag: keyof Exclude<IUserSchema['betaFlags'], undefined>,
546
) => {
547
  return user?.betaFlags?.[betaFlag]
1!
548
    ? ok(user)
549
    : err(
550
        new ForbiddenFormError(
551
          `User ${user.email} is not authorized to access ${betaFlag} beta features`,
552
        ),
553
      )
554
}
555

556
// Utility method to map an axios error to a GoGovError
557
export const mapGoGovErrors = (error: AxiosError): GoGovError => {
51✔
558
  type GoGovReturnedData = { message: string; type?: string }
559
  // Hard coded error message from GoGov for URL Bad Request
560
  // TODO: Verify with GoGov team if error response data can be different from general short link error
561
  const urlFormatError = 'Only HTTPS URLs are allowed'
×
562

563
  const responseData = error.response?.data as GoGovReturnedData
×
564

565
  switch (error.response?.status) {
×
566
    case StatusCodes.BAD_REQUEST:
567
      // There can be three types of Bad Request from GoGov
568
      // Short link already exists, which returns type=ShortUrlError
569
      // Or validation error, which does not contain type
570
      // Or if url is not https (like localhost), however, this should not happen as we prepend the app url in admin-form-controller
571
      // TODO: Update the conditional when GoGov upgrades their return type shape
572
      return responseData.type
×
573
        ? new GoGovAlreadyExistError()
574
        : !responseData.message.includes(urlFormatError)
×
575
        ? new GoGovValidationError()
576
        : new GoGovServerError(
577
            'GoGov server returned 400 for URL formatting error',
578
          )
579
    case StatusCodes.TOO_MANY_REQUESTS:
580
      return new GoGovRequestLimitError()
×
581
    // For gogov API this is equivalent to Request Failed
582
    case StatusCodes.PAYMENT_REQUIRED:
583
      return new GoGovBadGatewayError()
×
584
    // All other cases will default to 500 error
585
    default:
586
      return new GoGovServerError(
×
587
        `GoGov server returned ${error.response?.status} error code with ${
×
588
          (error.response?.data as GoGovReturnedData).message
×
589
        } message`,
590
      )
591
  }
592
}
593

594
// TODO: remove once we upgrade to Mongoose 7.x
595
/**
596
 * Manually insert default validation options for short text columns in tables.
597
 * Due to a bug in Mongoose 6.x, default values are not inherited for discriminators.
598
 *
599
 * See https://github.com/Automattic/mongoose/issues/12135
600
 *
601
 * Fixed in Mongoose 7.x
602
 * @param newField
603
 * @returns
604
 */
605

606
export const insertTableShortTextColumnDefaultValidationOptions = (
51✔
607
  newField: FieldUpdateDto,
608
) => {
609
  if (newField.fieldType === BasicField.Table) {
5!
610
    const defaultValidationOptions: TextValidationOptions = {
×
611
      customVal: null,
612
      selectedValidation: null,
613
    }
614
    newField.columns.map((column) => {
×
615
      if (column.columnType === BasicField.ShortText) {
×
616
        column.ValidationOptions = {
×
617
          ...defaultValidationOptions,
618
          ...column.ValidationOptions,
619
        }
620
      }
621
      return column
×
622
    })
623
  }
624
  return newField
5✔
625
}
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

© 2025 Coveralls, Inc