All files / src/tools calculateChanges.ts

100% Statements 69/69
86.79% Branches 46/53
100% Functions 17/17
100% Lines 61/61

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 2021x                         1x                     14x     14x   14x   14x     4x         10x         4x   4x   7x   2x 1x     1x                     6x         3x   3x 3x                   14x     1x                           90x 90x 90x 90x   90x 89x 95x 62x 62x 31x     64x     90x 172x 162x 162x 28x 27x 12x       150x     162x 89x 89x   48x     49x     36x   66x 66x                                             90x   172x           90x 64x 64x 64x   64x 64x 64x 69x   64x 3x 3x                 90x              
import log from '../logger';
import APIHandler from '../tools/auth0/handlers/default';
import { Asset, CalculatedChanges } from '../types';
 
/**
 * @template T
 * @param {typeof import('./auth0/handlers/default').default} handler
 * @param {T} desiredAssetState
 * @param {T} currentAssetState
 * @param {string[]} [objectFields=[]]
 * @param {boolean} [allowDelete]
 * @returns T
 */
export function processChangedObjectFields({
  handler,
  desiredAssetState,
  currentAssetState,
  allowDelete,
}: {
  handler: APIHandler;
  desiredAssetState: Asset;
  currentAssetState: Asset;
  allowDelete: boolean;
}) {
  const desiredAssetStateWithChanges = { ...desiredAssetState };
 
  // eslint-disable-next-line no-restricted-syntax
  for (const fieldName of handler.objectFields) {
    const areDesiredStateAndCurrentStateEmpty =
      Object.keys(desiredAssetState[fieldName] || {}).length === 0 &&
      Object.keys(currentAssetState[fieldName] || {}).length === 0;
    if (areDesiredStateAndCurrentStateEmpty) {
      // If both the desired state and current state for a given object is empty, it is a no-op and can skip
      // eslint-disable-next-line no-continue
      continue;
    }
 
    // A desired state that omits the objectField OR that has it as an empty object should
    // signal that all fields should be removed (subject to ALLOW_DELETE).
    if (desiredAssetState[fieldName] && Object.keys(desiredAssetState[fieldName]).length) {
      // Both the current and desired state have the object field. Here's where we need to map
      // to the APIv2 protocol of setting `null` values for deleted fields.
      // For new and modified properties of the object field, we can just pass them through to
      // APIv2.
      Eif (currentAssetState[fieldName]) {
        // eslint-disable-next-line no-restricted-syntax
        for (const currentObjectFieldPropertyName of Object.keys(currentAssetState[fieldName])) {
          // Loop through each object property that exists currently
          if (desiredAssetState[fieldName][currentObjectFieldPropertyName] === undefined) {
            // If the object has a property that exists now but doesn't exist in the proposed state
            if (allowDelete) {
              desiredAssetStateWithChanges[fieldName][currentObjectFieldPropertyName] = null;
            } else {
              // If deletes aren't allowed, do outright delete the property within the object
              log.warn(
                `Detected that the ${fieldName} of the following ${
                  handler.name || handler.id || ''
                } should be deleted. Doing so may be destructive.\nYou can enable deletes by setting 'AUTH0_ALLOW_DELETE' to true in the config\n${handler.objString(
                  currentAssetState
                )}`
              );
            }
          }
        }
      }
    } else if (allowDelete) {
      // If the desired state does not have the object field and the current state does, we
      // should mark *all* properties for deletion by specifying an empty object.
      //
      // See: https://auth0.com/docs/users/metadata/manage-metadata-api#delete-user-metadata
      desiredAssetStateWithChanges[fieldName] = {};
    } else {
      delete desiredAssetStateWithChanges[fieldName];
      log.warn(
        `Detected that the ${fieldName} of the following ${
          handler.name || handler.id || ''
        } should be emptied. Doing so may be destructive.\nYou can enable deletes by setting 'AUTH0_ALLOW_DELETE' to true in the config\n${handler.objString(
          currentAssetState
        )}`
      );
    }
  }
 
  return desiredAssetStateWithChanges;
}
 
export function calculateChanges({
  handler,
  assets,
  existing,
  identifiers = ['id', 'name'],
  allowDelete,
}: {
  handler: APIHandler;
  assets: Asset[];
  existing: Asset[];
  identifiers: string[];
  allowDelete: boolean;
}): CalculatedChanges {
  // Calculate the changes required between two sets of assets.
  const update: Asset[] = [];
  let del: Asset[] = [...existing];
  let create: Asset[] = [...assets];
  const conflicts: Asset[] = [];
 
  const findByKeyValue = (key: string, value: string, arr: Asset[]): Asset | undefined =>
    arr.find((e) => {
      if (Array.isArray(key)) {
        const values = key.map((k) => e[k]);
        Eif (values.every((v) => v)) {
          return value === values.join('-');
        }
      }
      return e[key] === value;
    });
 
  const processAssets = (id: string, arr: Asset[]) => {
    arr.forEach((asset) => {
      const assetIdValue: string | undefined = (() => {
        if (Array.isArray(id)) {
          const values = id.map((i) => asset[i]);
          if (values.every((v) => v)) {
            return values.join('-');
          }
        }
 
        return asset[id] as string;
      })();
 
      if (assetIdValue !== undefined) {
        const found = findByKeyValue(id, assetIdValue, del);
        if (found !== undefined) {
          // Delete from existing
          del = del.filter((e) => e !== found);
 
          // Delete from create as it's an update
          create = create.filter((e) => e !== asset);
 
          // Append identifiers to asset
          update.push({
            ...identifiers.reduce((obj, i) => {
              if (found[i]) obj[i] = found[i];
              return obj;
            }, {}),
            // If we have any object fields, we need to make sure that they get
            // special treatment. When different metadata objects are passed to APIv2
            // properties must explicitly be marked for deletion by indicating a `null`
            // value.
            ...(handler.objectFields.length
              ? processChangedObjectFields({
                  handler,
                  desiredAssetState: asset,
                  currentAssetState: found,
                  allowDelete,
                })
              : asset),
          });
        }
      }
    });
  };
 
  // Loop through identifiers (in order) to try match assets to existing
  // If existing then update if not create
  // The remainder will be deleted
  for (const id of identifiers) {
    // eslint-disable-line
    processAssets(id, [...create]);
  }
 
  // Check if there are assets with names that will conflict with existing names during the update process
  // This will rename those assets to a temp random name first
  // This assumes the first identifiers is the unique identifier
  if (identifiers.includes('name')) {
    const uniqueID = identifiers[0];
    const futureAssets: Asset[] = [...create, ...update];
    futureAssets.forEach((a) => {
      // If the conflicting item is going to be deleted then skip
      const inDeleted = del.filter((e) => e.name === a.name && e[uniqueID] !== a[uniqueID])[0];
      Eif (!inDeleted) {
        const conflict = existing.filter(
          (e) => e.name === a.name && e[uniqueID] !== a[uniqueID]
        )[0];
        if (conflict) {
          const temp = Math.random().toString(36).substr(2, 5);
          conflicts.push({
            ...conflict,
            name: `${conflict.name}-${temp}`,
          });
        }
      }
    });
  }
 
  return {
    del,
    update,
    conflicts,
    create,
  };
}