MM-T3180 Detox/E2E for LDAP login (#4863)

* E2E for LDAP login

* add LDAP test and sync

* update per comment

* add api commands to ensure LDAP user has team

* clean up

* clean up file check

* update per comment
This commit is contained in:
Saturnino Abril 2020-10-13 19:13:51 +08:00 committed by GitHub
parent 11898abce2
commit 64882b3176
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 382 additions and 134 deletions

4
.gitignore vendored
View file

@ -89,6 +89,10 @@ ios/sentry.properties
coverage
.tmp
# E2E testing
mattermost-license.txt
*.mattermost-license
# Bundle artifact
*.jsbundle

View file

@ -0,0 +1,32 @@
{
"dev-1": {
"username": "dev.one",
"password": "Password1",
"email": "success+devone@simulator.amazonses.com",
"userType": "Admin"
},
"dev-2": {
"username": "dev.two",
"password": "Password1",
"email": "success+testtwo@simulator.amazonses.com",
"userType": "Admin"
},
"test-1": {
"username": "test.one",
"password": "Password1",
"email": "success+testone@simulator.amazonses.com",
"userType": ""
},
"board-1": {
"username": "board.one",
"password": "Password1",
"email": "success+boardone@simulator.amazonses.com",
"userType": ""
},
"board-2": {
"username": "board.two",
"password": "Password1",
"email": "success+boardtwo@simulator.amazonses.com",
"userType": ""
}
}

View file

@ -1,6 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import FormData from 'form-data';
import fs from 'fs';
import client from './client';
export const getResponseFromError = (err) => {
const {response} = err;
if (!response) {
@ -19,3 +24,24 @@ If testing against a server other than the default local instance, you may set t
return {error: data, status};
};
export const apiUploadFile = async (name, absFilePath, requestOptions = {}) => {
if (!fs.existsSync(absFilePath)) {
return {error: {message: `File upload error. "${name}" file does not exist at ${absFilePath}`}};
}
const formData = new FormData();
formData.append(name, fs.createReadStream(absFilePath));
try {
const response = await client.request({
...requestOptions,
data: formData,
headers: formData.getHeaders(),
});
return response;
} catch (err) {
return getResponseFromError(err);
}
};

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import Channel from './channel';
import Ldap from './ldap';
import Post from './post';
import Setup from './setup';
import System from './system';
@ -10,6 +11,7 @@ import User from './user';
export {
Channel,
Ldap,
Post,
Setup,
System,

View file

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import jestExpect from 'expect';
import client from './client';
import {getResponseFromError} from './common';
// ****************************************************************
// LDAP
// See https://api.mattermost.com/#tag/LDAP
//
// Exported API function should have the following:
// - documented using JSDoc
// - meaningful description
// - match the referenced API endpoints
// - parameter/s defined by `@param`
// - return value defined by `@return`
// ****************************************************************
/**
* Synchronize any user attribute changes in the configured AD/LDAP server with Mattermost.
* See https://api.mattermost.com/#tag/LDAP/paths/~1ldap~1sync/post
* @return {string} returns {status} on success or {error, status} on error
*/
export const apiLDAPSync = async () => {
try {
const response = await client.post('/api/v4/ldap/sync');
return response;
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Test the current AD/LDAP configuration to see if the AD/LDAP server can be contacted successfully.
* See https://api.mattermost.com/#tag/LDAP/paths/~1ldap~1test/post
* @return {string} returns {status} on success or {error, status} on error
*/
export const apiLDAPTest = async () => {
try {
const response = await client.post('/api/v4/ldap/test');
return response.data;
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Check that LDAP server can connect and is synchronized with Mattermost server.
*/
export const apiRequireLDAPServer = async () => {
const {error: testError} = await apiLDAPTest();
jestExpect(testError).toBeUndefined();
const {error: syncError} = await apiLDAPSync();
jestExpect(syncError).toBeUndefined();
};
export const Ldap = {
apiLDAPSync,
apiLDAPTest,
apiRequireLDAPServer,
};
export default Ldap;

View file

@ -1,12 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import merge from 'merge-deep';
import merge from 'deepmerge';
import jestExpect from 'expect';
import path from 'path';
import testConfig from '@support/test_config';
import client from './client';
import {getResponseFromError} from './common';
import {apiUploadFile, getResponseFromError} from './common';
import defaultServerConfig from './default_config.json';
// ****************************************************************
@ -21,6 +23,8 @@ import defaultServerConfig from './default_config.json';
// - return value defined by `@return`
// ****************************************************************
/* eslint-disable no-console */
/**
* Check system health.
* See https://api.mattermost.com/#tag/system/paths/~1system~1ping/get
@ -57,7 +61,7 @@ export const apiGetConfig = async () => {
export const apiUpdateConfig = async (newConfig = {}) => {
try {
const {config: currentConfig} = await apiGetConfig();
const config = merge(currentConfig, getDefaultConfig(), newConfig);
const config = merge.all([currentConfig, getDefaultConfig(), newConfig]);
const response = await client.put(
'/api/v4/config',
@ -70,18 +74,117 @@ export const apiUpdateConfig = async (newConfig = {}) => {
}
};
const getDefaultConfig = () => {
function getDefaultConfig() {
const fromEnv = {
LdapSettings: {
LdapServer: testConfig.ldapServer,
LdapPort: testConfig.ldapPort,
},
ServiceSettings: {SiteURL: testConfig.siteUrl},
};
return merge(defaultServerConfig, fromEnv);
}
/**
* Get client license.
* See https://api.mattermost.com/#tag/system/paths/~1license~1client/get
* @return {Object} returns {license} on success or {error, status} on error
*/
export const apiGetClientLicense = async () => {
try {
const response = await client.get('/api/v4/license/client?format=old');
return {license: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Require server license to successfully continue.
* @return {Object} returns {license} on success or fail when no license
*/
export const apiRequireLicense = async () => {
const {license} = await getClientLicense();
if (license.IsLicensed !== 'true') {
console.error('Server has no Enterprise license.');
}
jestExpect(license.IsLicensed).toEqual('true');
return {license};
};
/**
* Require server license with specific feature to successfully continue.
* @param {string} key - feature, e.g. LDAP
* @return {Object} returns {license} on success or fail when no license or no license to specific feature.
*/
export const apiRequireLicenseForFeature = async (key = '') => {
const {license} = await getClientLicense();
if (license.IsLicensed !== 'true') {
console.error('Server has no Enterprise license.');
}
jestExpect(license.IsLicensed).toEqual('true');
let hasLicenseKey = false;
for (const [k, v] of Object.entries(license)) {
if (k === key && v === 'true') {
hasLicenseKey = true;
break;
}
}
if (!hasLicenseKey) {
console.error(`Server has no license for "${key}" feature.`);
}
jestExpect(hasLicenseKey).toEqual(true);
return {license};
};
/**
* Upload server license with file expected at "/detox/e2e/support/fixtures/mattermost-license.txt"
*/
export const apiUploadLicense = async () => {
const absFilePath = path.resolve(__dirname, '../../support/fixtures/mattermost-license.txt');
const response = await apiUploadFile('license', absFilePath, {url: '/api/v4/license', method: 'POST'});
return response;
};
/**
* Get client license.
* If no license, try to upload if license file is available at "/support/fixtures/mattermost-license.txt".
*/
async function getClientLicense() {
const {license} = await apiGetClientLicense();
if (license.IsLicensed === 'true') {
return {license};
}
// Upload a license if server is currently not loaded with license
const response = await apiUploadLicense();
if (response.error) {
console.warn(response.error.message);
return {license};
}
// Get an updated client license
const out = await apiGetClientLicense();
return {license: out.license};
}
export const System = {
apiCheckSystemHealth,
apiGetConfig,
apiUpdateConfig,
apiGetClientLicense,
apiRequireLicense,
apiRequireLicenseForFeature,
apiUploadLicense,
};
export default System;

View file

@ -59,6 +59,22 @@ export const apiAddUserToTeam = async (userId, teamId) => {
}
};
/**
* Get team members for user.
* See https://api.mattermost.com/#tag/teams/paths/~1users~1{user_id}~1teams~1members/get
* @param {string} userId
* @return {Object} returns {teams} on success or {error, status} on error
*/
export const apiGetTeamMembersForUser = async (userId = 'me') => {
try {
const response = await client.get(`/api/v4/users/${userId}/teams`);
return {teams: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
function generateRandomTeam(type, prefix) {
const randomId = getRandomId();
@ -72,6 +88,7 @@ function generateRandomTeam(type, prefix) {
export const Team = {
apiAddUserToTeam,
apiCreateTeam,
apiGetTeamMembersForUser,
};
export default Team;

View file

@ -111,6 +111,21 @@ export const apiGetUserById = async (userId) => {
}
};
/**
* Get a user by username.
* See https://api.mattermost.com/#tag/users/paths/~1users~1username~1{username}/get
* @param {string} username
*/
export const apiGetUserByUsername = async (username) => {
try {
const response = await client.get(`/api/v4/users/username/${username}`);
return {user: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
function generateRandomUser(prefix) {
const randomId = getRandomId();
@ -131,6 +146,7 @@ export const User = {
apiCreateUser,
apiGetMe,
apiGetUserById,
apiGetUserByUsername,
};
export default User;

View file

@ -6,4 +6,6 @@ module.exports = {
siteUrl: process.env.SITE_URL || 'http://localhost:8065',
adminUsername: process.env.ADMIN_USERNAME || 'sysadmin',
adminPassword: process.env.ADMIN_PASSWORD || 'Sys@dmin-sample1',
ldapServer: process.env.LDAP_SERVER || 'localhost',
ldapPort: process.env.LDAP_PORT || 389,
};

View file

@ -36,12 +36,12 @@ export async function fulfillLoginScreen(user = {}) {
await element(by.id('username_input')).replaceText(user.username);
// # Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.id('login_screen')).tap({x: 10, y: 10});
await element(by.id('password_input')).replaceText(user.password);
// # Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.id('login_screen')).tap({x: 10, y: 10});
await element(by.id('signin_button')).tap();
}

View file

@ -7,7 +7,7 @@
// - Use element testID when selecting an element. Create one if none.
// *******************************************************************
import nodeExpect from 'expect';
import jestExpect from 'expect';
import {toChannelScreen} from '@support/ui/screen';
import {Channel, Post, Setup} from '@support/server_api';
@ -48,9 +48,9 @@ describe('Messaging', () => {
// # Check that no duplicate message is saved.
const {channel} = await Channel.apiGetChannelByName(team.name, 'town-square');
const {posts} = await Post.apiGetPostsInChannel(channel.id);
nodeExpect(posts.length).toEqual(3);
nodeExpect(posts[0].message).toEqual(message);
nodeExpect(posts[1].message).toEqual(`${user.username} joined the team.`);
nodeExpect(posts[2].message).toEqual('sysadmin joined the team.');
jestExpect(posts.length).toEqual(3);
jestExpect(posts[0].message).toEqual(message);
jestExpect(posts[1].message).toEqual(`${user.username} joined the team.`);
jestExpect(posts[2].message).toEqual('sysadmin joined the team.');
});
});

View file

@ -7,15 +7,17 @@
// - Use element testID when selecting an element. Create one if none.
// *******************************************************************
import {Setup} from '@support/server_api';
import {Setup, System} from '@support/server_api';
import {serverUrl} from '@support/test_config';
import {fulfillSelectServerScreen} from '@support/ui/screen';
import {isAndroid, timeouts, wait} from '@support/utils';
describe('On boarding', () => {
let config;
let user;
beforeAll(async () => {
({config} = await System.apiGetConfig());
({user} = await Setup.apiInit());
});
@ -89,7 +91,7 @@ describe('On boarding', () => {
await element(by.id('username_input')).typeText('any');
// Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.text(config.TeamSettings.SiteName)).tap();
// Tap "Sign in" button
await element(by.id('signin_button')).tap();
@ -103,7 +105,7 @@ describe('On boarding', () => {
await element(by.id('password_input')).typeText('any');
// Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.text(config.TeamSettings.SiteName)).tap();
// Tap "Sign in" button
await element(by.id('signin_button')).tap();
@ -122,13 +124,13 @@ describe('On boarding', () => {
await element(by.id('username_input')).replaceText('any');
// Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.text(config.TeamSettings.SiteName)).tap();
// Enter invalid password
await element(by.id('password_input')).replaceText('any');
// Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.text(config.TeamSettings.SiteName)).tap();
// Tap "Sign in" button
await element(by.id('signin_button')).tap();
@ -147,13 +149,13 @@ describe('On boarding', () => {
await element(by.id('username_input')).replaceText(user.username);
// # Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.text(config.TeamSettings.SiteName)).tap();
// Enter valid password
await element(by.id('password_input')).replaceText(user.password);
// # Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.text(config.TeamSettings.SiteName)).tap();
// Tap "Sign in" button
await element(by.id('signin_button')).tap();

View file

@ -7,11 +7,12 @@
// - Use element testID when selecting an element. Create one if none.
// *******************************************************************
import {Setup} from '@support/server_api';
import {Setup, System} from '@support/server_api';
import {serverUrl} from '@support/test_config';
describe('Smoke Tests', () => {
it('MM-T3179 Log in - Email / password', async () => {
const {config} = await System.apiGetConfig();
const {user} = await Setup.apiInit();
// * Verify that it starts with the Select Server screen
@ -30,13 +31,13 @@ describe('Smoke Tests', () => {
await element(by.id('username_input')).replaceText(user.username);
// # Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.text(config.TeamSettings.SiteName)).tap();
// # Type in password
await element(by.id('password_input')).replaceText(user.password);
// # Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.text(config.TeamSettings.SiteName)).tap();
// # Tap "Sign in" button
await element(by.text('Sign in')).tap();

View file

@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Ldap, Setup, System, Team, User} from '@support/server_api';
import {serverUrl} from '@support/test_config';
import ldapUsers from '@support/fixtures/ldap_users.json';
describe('Smoke Tests', () => {
const testOne = ldapUsers['test-1'];
let config;
beforeAll(async () => {
// * Verify that the server has license with LDAP feature
await System.apiRequireLicenseForFeature('LDAP');
// # Enable LDAP
({config} = await System.apiUpdateConfig({LdapSettings: {Enable: true}}));
// * Check that LDAP server can connect and is synchronized with Mattermost server
await Ldap.apiRequireLDAPServer();
await ensureUserHasTeam(testOne);
});
it('MM-T3180 Log in - LDAP', async () => {
// * Verify that it starts with the Select Server screen
await expect(element(by.id('select_server_screen'))).toBeVisible();
// # Type in the server URL
await element(by.id('server_url_input')).replaceText(serverUrl);
// # Tap connect button
await element(by.text('Connect')).tap();
// # Verify that it goes into Login screen
await expect(element(by.id('login_screen'))).toBeVisible();
// # Type in username
await element(by.id('username_input')).replaceText(testOne.username);
// # Tap anywhere to hide keyboard
await element(by.text(config.TeamSettings.SiteName)).tap();
// # Type in password
await element(by.id('password_input')).replaceText(testOne.password);
// # Tap anywhere to hide keyboard
await element(by.text(config.TeamSettings.SiteName)).tap();
// # Tap "Sign in" button
await element(by.text('Sign in')).tap();
// * Verify that the user has successfully logged in by checking it redirects into the Channel screen
await expect(element(by.id('channel_screen'))).toBeVisible();
});
});
async function ensureUserHasTeam(ldapUser) {
// # Login as LDAP user to sync with the server
await User.apiLogin(ldapUser);
// # Login as sysadmin and ensure LDAP user is member of at least one team
await User.apiAdminLogin();
const {user} = await User.apiGetUserByUsername(ldapUser.username);
const {teams} = await Team.apiGetTeamMembersForUser(user.id);
if (!teams?.length) {
const {team} = await Setup.apiInit();
await Team.apiAddUserToTeam(user.id, team.id);
}
}

127
detox/package-lock.json generated
View file

@ -2319,30 +2319,6 @@
"wrap-ansi": "^5.1.0"
}
},
"clone-deep": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz",
"integrity": "sha1-TnPdCen7lxzDhnDF3O2cGJZIHMY=",
"dev": true,
"requires": {
"for-own": "^0.1.3",
"is-plain-object": "^2.0.1",
"kind-of": "^3.0.2",
"lazy-cache": "^1.0.3",
"shallow-clone": "^0.1.2"
},
"dependencies": {
"kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
"dev": true,
"requires": {
"is-buffer": "^1.1.5"
}
}
}
},
"co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -3077,15 +3053,6 @@
"integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=",
"dev": true
},
"for-own": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz",
"integrity": "sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=",
"dev": true,
"requires": {
"for-in": "^1.0.1"
}
},
"forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
@ -3093,13 +3060,13 @@
"dev": true
},
"form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz",
"integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
@ -5330,12 +5297,6 @@
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
"dev": true
},
"lazy-cache": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz",
"integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=",
"dev": true
},
"leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -5448,28 +5409,6 @@
"object-visit": "^1.0.0"
}
},
"merge-deep": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.2.tgz",
"integrity": "sha512-T7qC8kg4Zoti1cFd8Cr0M+qaZfOwjlPDEdZIIPPB2JZctjaPM4fX+i7HOId69tAti2fvO6X5ldfYUONDODsrkA==",
"dev": true,
"requires": {
"arr-union": "^3.1.0",
"clone-deep": "^0.2.4",
"kind-of": "^3.0.2"
},
"dependencies": {
"kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
"dev": true,
"requires": {
"is-buffer": "^1.1.5"
}
}
}
},
"merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -5543,24 +5482,6 @@
}
}
},
"mixin-object": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz",
"integrity": "sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4=",
"dev": true,
"requires": {
"for-in": "^0.1.3",
"is-extendable": "^0.1.1"
},
"dependencies": {
"for-in": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz",
"integrity": "sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE=",
"dev": true
}
}
},
"mkdirp": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
@ -6289,6 +6210,17 @@
"uuid": "^3.3.2"
},
"dependencies": {
"form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
}
},
"tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
@ -6653,35 +6585,6 @@
}
}
},
"shallow-clone": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz",
"integrity": "sha1-WQnodLp3EG1zrEFM/sH/yofZcGA=",
"dev": true,
"requires": {
"is-extendable": "^0.1.1",
"kind-of": "^2.0.1",
"lazy-cache": "^0.2.3",
"mixin-object": "^2.0.1"
},
"dependencies": {
"kind-of": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz",
"integrity": "sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU=",
"dev": true,
"requires": {
"is-buffer": "^1.0.2"
}
},
"lazy-cache": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz",
"integrity": "sha1-f+3fLctu23fRHvHRF6tf/fCrG2U=",
"dev": true
}
}
},
"shebang-command": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",

View file

@ -10,11 +10,12 @@
"axios": "0.20.0",
"babel-jest": "26.3.0",
"babel-plugin-module-resolver": "4.0.0",
"deepmerge": "4.2.2",
"detox": "17.4.4",
"form-data": "3.0.0",
"jest": "26.4.2",
"jest-circus": "26.4.2",
"jest-cli": "26.4.2",
"merge-deep": "3.0.2",
"uuid": "8.3.0"
},
"scripts": {