Repository: serverless/serverless-azure-functions
Pull request #183 (2019-06-27 22:03:14)

 feat: Updates to deployment process to enable rollback (Part 1)
Updating the deployment process will take a few pull requests to keep them (relatively) small. Here are the changes included in this pull request.
- Update default naming convention of Azure Storage account (and a utility function that assists with creating name)
- Stubs for rollback enabled (currently switched off to avoid breaking tests, will switch on in future PR), which includes
    - Timestamping names of deployments
    - Timestamping names of packages
    - Initialization of deployment artifact container
- Updates the `LoginService` to accept options, which is used in the `AzureBlobStorageService` to retrieve a storage-specific access token
- Remove dead code from `FunctionAppService`

Resolves [AB#357], [AB#408], [AB#414], [AB#415]


Patch:
diff --git a/src/armTemplates/resources/storageAccount.ts b/src/armTemplates/resources/storageAccount.ts
index b786e98..0c59f8b 100644
--- a/src/armTemplates/resources/storageAccount.ts
+++ b/src/armTemplates/resources/storageAccount.ts
@@ -1,11 +1,12 @@
 import { ArmResourceTemplateGenerator, ArmResourceTemplate } from "../../models/armTemplates";
 import { ServerlessAzureConfig, ResourceConfig } from "../../models/serverless";
+import { Utils } from "../../shared/utils";
 
 export class StorageAccountResource implements ArmResourceTemplateGenerator {
   public static getResourceName(config: ServerlessAzureConfig) {
     return config.provider.storageAccount && config.provider.storageAccount.name
       ? config.provider.storageAccount.name
-      : `${config.provider.prefix}${config.provider.region.substr(0, 3)}${config.provider.stage.substr(0, 3)}sa`.replace("-", "").toLocaleLowerCase();
+      : StorageAccountResource.getDefaultStorageAccountName(config)
   }
 
   public getTemplate(): ArmResourceTemplate {
@@ -62,4 +63,23 @@ export class StorageAccountResource implements ArmResourceTemplateGenerator {
       storageAccoutSkuTier: resourceConfig.sku.tier,
     };
   }
+
+  /**
+   * Gets a default storage account name.
+   * Storage account names can have at most 24 characters and can have only alpha-numerics
+   * Default naming convention:
+   * 
+   * "(first 3 of prefix)(first 3 of region)(first 3 of stage)(first 12 of service)sa"
+   * (Maximum of 23 characters)
+   * @param config Serverless Azure Config
+   */
+  private static getDefaultStorageAccountName(config: ServerlessAzureConfig): string {
+    const prefix = Utils.appendSubstrings(
+      3,
+      config.provider.prefix,
+      config.provider.region,
+      config.provider.stage,
+    );
+    return `${prefix}${config.service.substr(0, 12)}sa`.replace("-", "").toLocaleLowerCase();
+  }
 }
\ No newline at end of file
diff --git a/src/config.ts b/src/config.ts
index f2c3643..2f08214 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,5 +1,6 @@
-export const constants = {
+export const configConstants = {
   bearer: "Bearer ",
+  deploymentArtifactContainer: "deployment-artifacts",
   functionAppApiPath: "/api/",
   functionAppDomain: ".azurewebsites.net",
   functionsAdminApiPath: "/admin/functions/",
@@ -10,10 +11,11 @@ export const constants = {
   logStreamApiPath: "/api/logstream/application/functions/function/",
   masterKeyApiPath: "/api/functions/admin/masterkey",
   providerName: "azure",
+  rollbackEnabled: false,
   scmCommandApiPath: "/api/command",
   scmDomain: ".scm.azurewebsites.net",
   scmVfsPath: "/api/vfs/site/wwwroot/",
   scmZipDeployApiPath: "/api/zipdeploy"
 };
 
-export default constants;
\ No newline at end of file
+export default configConstants;
\ No newline at end of file
diff --git a/src/models/serverless.ts b/src/models/serverless.ts
index b3f6d31..f50d024 100644
--- a/src/models/serverless.ts
+++ b/src/models/serverless.ts
@@ -23,6 +23,11 @@ export interface FunctionAppConfig extends ResourceConfig {
   extensionVersion?;
 }
 
+export interface DeploymentConfig {
+  rollback?: boolean;
+  container?: string;
+}
+
 export interface ServerlessAzureConfig {
   service: string;
   provider: {
diff --git a/src/plugins/deploy/azureDeployPlugin.ts b/src/plugins/deploy/azureDeployPlugin.ts
index 485e771..00b1941 100644
--- a/src/plugins/deploy/azureDeployPlugin.ts
+++ b/src/plugins/deploy/azureDeployPlugin.ts
@@ -1,6 +1,6 @@
 import Serverless from "serverless";
-import { ResourceService } from "../../services/resourceService";
 import { FunctionAppService } from "../../services/functionAppService";
+import { ResourceService } from "../../services/resourceService";
 
 export class AzureDeployPlugin {
   public hooks: { [eventName: string]: Promise<any> };
@@ -48,9 +48,11 @@ export class AzureDeployPlugin {
 
   private async deploy() {
     const resourceService = new ResourceService(this.serverless, this.options);
+
     await resourceService.deployResourceGroup();
 
     const functionAppService = new FunctionAppService(this.serverless, this.options);
+    await functionAppService.initialize();
 
     const functionApp = await functionAppService.deploy();
     await functionAppService.uploadFunctions(functionApp);
diff --git a/src/services/armService.ts b/src/services/armService.ts
index ce13d0c..90feef5 100644
--- a/src/services/armService.ts
+++ b/src/services/armService.ts
@@ -111,9 +111,12 @@ export class ArmService extends BaseService {
     };
 
     // Deploy ARM template
-    this.serverless.cli.log("-> Deploying ARM template...");
+    this.log("-> Deploying ARM template...");
+    this.log(`---> Resource Group: ${this.resourceGroup}`)
+    this.log(`---> Deployment Name: ${this.deploymentName}`)
+
     const result = await this.resourceClient.deployments.createOrUpdate(this.resourceGroup, this.deploymentName, armDeployment);
-    this.serverless.cli.log("-> ARM deployment complete");
+    this.log("-> ARM deployment complete");
 
     return result;
   }
diff --git a/src/services/azureBlobStorageService.ts b/src/services/azureBlobStorageService.ts
index 9946cea..8726dc0 100644
--- a/src/services/azureBlobStorageService.ts
+++ b/src/services/azureBlobStorageService.ts
@@ -1,7 +1,8 @@
-import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, StorageURL, uploadFileToBlockBlob } from "@azure/storage-blob";
+import { Aborter, BlockBlobURL, ContainerURL, Credential, ServiceURL, StorageURL, TokenCredential, uploadFileToBlockBlob } from "@azure/storage-blob";
 import Serverless from "serverless";
-import { BaseService } from "./baseService";
 import { Guard } from "../shared/guard";
+import { BaseService } from "./baseService";
+import { AzureLoginService } from "./loginService";
 
 /**
  * Wrapper for operations on Azure Blob Storage account
@@ -12,12 +13,17 @@ export class AzureBlobStorageService extends BaseService {
    * Account URL for Azure Blob Storage account. Depends on `storageAccountName` being set in baseService
    */
   private accountUrl: string;
+  private storageCredential: Credential;
 
   public constructor(serverless: Serverless, options: Serverless.Options) {
     super(serverless, options);
     this.accountUrl = `https://${this.storageAccountName}.blob.core.windows.net`;
   }
 
+  public async initialize() {
+    this.storageCredential = new TokenCredential(await this.getToken());
+  }
+
   /**
    * Upload a file to Azure Blob Storage
    * @param path Path of file to upload
@@ -114,7 +120,7 @@ export class AzureBlobStorageService extends BaseService {
    * Get ServiceURL object for Azure Blob Storage Account
    */
   private getServiceURL(): ServiceURL {
-    const pipeline = StorageURL.newPipeline(this.credentials);
+    const pipeline = StorageURL.newPipeline(this.storageCredential);
     const accountUrl = this.accountUrl;
     const serviceUrl = new ServiceURL(
       accountUrl,
@@ -149,4 +155,12 @@ export class AzureBlobStorageService extends BaseService {
       blobName,
     );
   }
+
+  private async getToken(): Promise<string> {
+    const authResponse = await AzureLoginService.login({
+      tokenAudience: "https://storage.azure.com/"
+    });
+    const token = await authResponse.credentials.getToken();
+    return token.accessToken;
+  }
 }
diff --git a/src/services/baseService.ts b/src/services/baseService.ts
index c45a86a..642da6b 100644
--- a/src/services/baseService.ts
+++ b/src/services/baseService.ts
@@ -2,16 +2,20 @@ import axios from "axios";
 import fs from "fs";
 import request from "request";
 import Serverless from "serverless";
+import { StorageAccountResource } from "../armTemplates/resources/storageAccount";
+import { configConstants } from "../config";
+import { DeploymentConfig, ServerlessAzureConfig } from "../models/serverless";
 import { Guard } from "../shared/guard";
-import { ServerlessAzureConfig } from "../models/serverless";
+import { TokenCredentialsBase } from "@azure/ms-rest-nodeauth";
 
 export abstract class BaseService {
   protected baseUrl: string;
   protected serviceName: string;
-  protected credentials: any;
+  protected credentials: TokenCredentialsBase
   protected subscriptionId: string;
   protected resourceGroup: string;
   protected deploymentName: string;
+  protected deploymentConfig: DeploymentConfig
   protected deploymentContainerName: string;
   protected storageAccountName: string;
   protected config: ServerlessAzureConfig;
@@ -26,12 +30,14 @@ export abstract class BaseService {
     this.setDefaultValues();
 
     this.baseUrl = "https://management.azure.com";
+    this.serviceName = this.getServiceName();
     this.config = serverless.service as any;
-    this.serviceName = serverless.service["service"];
     this.credentials = serverless.variables["azureCredentials"];
     this.subscriptionId = serverless.variables["subscriptionId"];
     this.resourceGroup = this.getResourceGroupName();
-    this.deploymentName = serverless.service.provider["deploymentName"] || `${this.resourceGroup}-deployment`;
+    this.deploymentConfig = this.getDeploymentConfig();
+    this.deploymentName = this.getDeploymentName();
+    this.storageAccountName = StorageAccountResource.getResourceName(serverless.service as any)
 
     if (!this.credentials && authenticate) {
       throw new Error(`Azure Credentials has not been set in ${this.constructor.name}`);
@@ -52,6 +58,45 @@ export abstract class BaseService {
       || `${this.config.provider.prefix}-${this.getRegion()}-${this.getStage()}-${this.serviceName}-rg`;
   }
 
+  public getDeploymentConfig(): DeploymentConfig {
+    const providedConfig = this.serverless["deploy"] as DeploymentConfig;
+    const providedDepName = this.serverless.service.provider["deploymentName"];
+    if (providedConfig && providedDepName && providedConfig.rollback) {
+      throw new Error("Cannot both specify a deployment name and enable rollback. " +
+      "In order for rollback to work, the name of deployment must follow the generated " +
+      "naming convention")
+    }
+    const config = providedConfig || {
+      rollback: configConstants.rollbackEnabled,
+      container: configConstants.deploymentArtifactContainer,
+    }
+    return config;
+  }
+
+  public getDeploymentName(): string {
+    const name = this.serverless.service.provider["deploymentName"] || `${this.resourceGroup}-deployment`
+    return this.rollbackConfiguredName(name);
+  }
+
+  public getServiceName(): string {
+    return this.serverless.service["service"];
+  }
+
+  /**
+   * Get rollback-configured artifact name. Contains `-t{timestamp}`
+   * if rollback is configured
+   */
+  public getArtifactName(): string {
+    return this.rollbackConfiguredName(this.getServiceName());
+  }
+
+  /**
+   * Get the access token from credentials token cache
+   */
+  protected getAccessToken(): string{
+    return (this.credentials.tokenCache as any)._entries[0].accessToken;
+  }
+
   /**
    * Sends an API request using axios HTTP library
    * @param method The HTTP method
@@ -60,7 +105,7 @@ export abstract class BaseService {
    */
   protected async sendApiRequest(method: string, relativeUrl: string, options: any = {}) {
     const defaultHeaders = {
-      Authorization: `Bearer ${this.credentials.tokenCache._entries[0].accessToken}`,
+      Authorization: `Bearer ${this.getAccessToken()}`,
     };
 
     const allHeaders = {
@@ -125,4 +170,25 @@ export abstract class BaseService {
       this.serverless.service.provider["prefix"] = "sls";
     }
   }
+
+  /**
+   * Add `-t{timestamp}` if rollback is enabled
+   * @param name Original name
+   */
+  private rollbackConfiguredName(name: string) {
+    return (this.deploymentConfig.rollback) ? `${name}-t${this.getTimestamp()}` : name;
+  }
+
+  /**
+   * Get timestamp from `packageTimestamp` serverless variable
+   * If not set, create timestamp, set variable and return timestamp
+   */
+  private getTimestamp(): number {
+    let timestamp = +this.serverless.variables["packageTimestamp"];
+    if (!timestamp) {
+      timestamp = Date.now();
+      this.serverless.variables["packageTimestamp"] = timestamp;
+    }
+    return timestamp;
+  }
 }
diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts
index e83fd6d..eb1a717 100644
--- a/src/services/functionAppService.ts
+++ b/src/services/functionAppService.ts
@@ -1,14 +1,15 @@
+import { WebSiteManagementClient } from "@azure/arm-appservice";
+import { FunctionEnvelope, Site } from "@azure/arm-appservice/esm/models";
 import fs from "fs";
 import path from "path";
-import { WebSiteManagementClient } from "@azure/arm-appservice";
 import Serverless from "serverless";
-import { BaseService } from "./baseService";
+import { FunctionAppResource } from "../armTemplates/resources/functionApp";
+import { ArmDeployment } from "../models/armTemplates";
 import { FunctionAppHttpTriggerConfig } from "../models/functionApp";
-import { Site, FunctionEnvelope } from "@azure/arm-appservice/esm/models";
 import { Guard } from "../shared/guard";
 import { ArmService } from "./armService";
-import { ArmDeployment } from "../models/armTemplates";
-import { FunctionAppResource } from "../armTemplates/resources/functionApp";
+import { AzureBlobStorageService } from "./azureBlobStorageService";
+import { BaseService } from "./baseService";
 
 export class FunctionAppService extends BaseService {
   private webClient: WebSiteManagementClient;
@@ -42,6 +43,17 @@ export class FunctionAppService extends BaseService {
     return response.data.value;
   }
 
+  /**
+   * Initialize deployment artifact container if rollback is specified
+   */
+  public async initialize() {
+    if (this.deploymentConfig.rollback) {
+      const blobService = new AzureBlobStorageService(this.serverless, this.options);
+      await blobService.initialize();
+      blobService.createContainer(this.deploymentConfig.container);
+    }
+  }
+
   public async deleteFunction(functionApp: Site, functionName: string) {
     Guard.null(functionApp);
     Guard.empty(functionName);
@@ -109,7 +121,8 @@ export class FunctionAppService extends BaseService {
   public async uploadFunctions(functionApp: Site): Promise<any> {
     Guard.null(functionApp, "functionApp");
 
-    this.log("Deploying serverless functions...");
+    this.log("Deploying serverless functions...");    
+
     await this.zipDeploy(functionApp);
   }
 
@@ -132,10 +145,9 @@ export class FunctionAppService extends BaseService {
   }
 
   private async zipDeploy(functionApp) {
-    const functionAppName = functionApp.name;
     const scmDomain = this.getScmDomain(functionApp);
 
-    this.serverless.cli.log(`Deploying zip file to function app: ${functionAppName}`);
+    this.serverless.cli.log(`Deploying zip file to function app: ${functionApp.name}`);
 
     // Upload function artifact if it exists, otherwise the full service is handled in 'uploadFunctions' method
     let functionZipFile = this.serverless.service["artifact"];
@@ -155,7 +167,7 @@ export class FunctionAppService extends BaseService {
       uri: `https://${scmDomain}/api/zipdeploy/`,
       json: true,
       headers: {
-        Authorization: `Bearer ${this.credentials.tokenCache._entries[0].accessToken}`,
+        Authorization: `Bearer ${this.getAccessToken()}`,
         Accept: "*/*",
         ContentType: "application/octet-stream",
       }
@@ -201,29 +213,6 @@ export class FunctionAppService extends BaseService {
     };
   }
 
-  private async runKuduCommand(functionApp: Site, command: string) {
-    this.serverless.cli.log(`-> Running Kudu command ${command}...`);
-
-    const scmDomain = this.getScmDomain(functionApp);
-    const requestUrl = `https://${scmDomain}/api/command`;
-
-    // TODO: There is a case where the body will contain an error, but it's
-    // not actually an error. These are warnings from npm install.
-    const response = await this.sendApiRequest("POST", requestUrl, {
-      data: {
-        command: command,
-        dir: "site\\wwwroot"
-      }
-    });
-
-    if (response.status !== 200) {
-      if (response.data && response.data.Error) {
-        throw new Error(response.data.Error);
-      }
-      throw new Error(`Error executing ${command} command, try again later.`);
-    }
-  }
-
   /**
    * Gets a short lived admin token used to retrieve function keys
    */
diff --git a/src/services/loginService.ts b/src/services/loginService.ts
index b6afa60..736671a 100644
--- a/src/services/loginService.ts
+++ b/src/services/loginService.ts
@@ -3,28 +3,36 @@ import {
   interactiveLoginWithAuthResponse,
   loginWithServicePrincipalSecretWithAuthResponse,
   AuthResponse,
+  AzureTokenCredentialsOptions,
+  InteractiveLoginOptions,  
 } from "@azure/ms-rest-nodeauth";
 
 export class AzureLoginService {
-  public static async login(): Promise<AuthResponse> {
+
+  /**
+   * Logs in via service principal login if environment variables are
+   * set or via interactive login if environment variables are not set
+   * @param options Options for different authentication methods
+   */
+  public static async login(options?: AzureTokenCredentialsOptions|InteractiveLoginOptions): Promise<AuthResponse> {
     const subscriptionId = process.env.azureSubId;
     const clientId = process.env.azureServicePrincipalClientId;
     const secret = process.env.azureServicePrincipalPassword;
     const tenantId = process.env.azureServicePrincipalTenantId;
 
     if (subscriptionId && clientId && secret && tenantId) {
-      return await AzureLoginService.servicePrincipalLogin(clientId, secret, tenantId);
+      return await AzureLoginService.servicePrincipalLogin(clientId, secret, tenantId, options);
     } else {
-      return await AzureLoginService.interactiveLogin();
+      return await AzureLoginService.interactiveLogin(options);
     }
   }
 
-  public static async interactiveLogin(): Promise<AuthResponse> {
+  public static async interactiveLogin(options: InteractiveLoginOptions): Promise<AuthResponse> {
     await open("https://microsoft.com/devicelogin");
-    return await interactiveLoginWithAuthResponse();
+    return await interactiveLoginWithAuthResponse(options);
   }
 
-  public static async servicePrincipalLogin(clientId: string, secret: string, tenantId: string): Promise<AuthResponse> {
-    return loginWithServicePrincipalSecretWithAuthResponse(clientId, secret, tenantId);
+  public static async servicePrincipalLogin(clientId: string, secret: string, tenantId: string, options: AzureTokenCredentialsOptions): Promise<AuthResponse> {
+    return loginWithServicePrincipalSecretWithAuthResponse(clientId, secret, tenantId, options);
   }
 }
diff --git a/src/services/packageService.ts b/src/services/packageService.ts
index 1bcd943..89127a4 100644
--- a/src/services/packageService.ts
+++ b/src/services/packageService.ts
@@ -1,7 +1,7 @@
-import Serverless from "serverless";
-import path from "path";
 import fs from "fs";
-import { Utils, FunctionMetadata } from "../shared/utils";
+import path from "path";
+import Serverless from "serverless";
+import { FunctionMetadata, Utils } from "../shared/utils";
 
 /**
  * Adds service packing support
diff --git a/src/shared/utils.ts b/src/shared/utils.ts
index f8102a9..8777c34 100644
--- a/src/shared/utils.ts
+++ b/src/shared/utils.ts
@@ -1,5 +1,5 @@
-import Serverless from "serverless";
 import { relative } from "path";
+import Serverless from "serverless";
 import { BindingUtils } from "./bindings";
 import { constants } from "./constants";
 
@@ -123,4 +123,17 @@ export class Utils {
     const vals = params.values();
     return new Function(...names, `return \`${template}\`;`)(...vals);
   }
+
+  /**
+   * Take the first `substringSize` characters from each string and return as one string
+   * @param substringSize Size of substring to take from beginning of each string
+   * @param args Strings to take substrings from
+   */
+  public static appendSubstrings(substringSize: number, ...args: string[]): string {
+    let result = "";
+    for (const s of args) {
+      result += (s.substr(0, substringSize));
+    }
+    return result;
+  }
 }


Test patch:
diff --git a/src/plugins/login/loginPlugin.test.ts b/src/plugins/login/loginPlugin.test.ts
index 6bf138e..fe1fa89 100644
--- a/src/plugins/login/loginPlugin.test.ts
+++ b/src/plugins/login/loginPlugin.test.ts
@@ -60,7 +60,8 @@ describe("Login Plugin", () => {
     expect(AzureLoginService.servicePrincipalLogin).toBeCalledWith(
       "azureServicePrincipalClientId",
       "azureServicePrincipalPassword",
-      "azureServicePrincipalTenantId"
+      "azureServicePrincipalTenantId",
+      undefined // would be options
     )
     expect(AzureLoginService.interactiveLogin).not.toBeCalled();
     expect(sls.variables["azureCredentials"]).toEqual(credentials);
diff --git a/src/services/azureBlobStorageService.test.ts b/src/services/azureBlobStorageService.test.ts
index 9302e14..e8d10a2 100644
--- a/src/services/azureBlobStorageService.test.ts
+++ b/src/services/azureBlobStorageService.test.ts
@@ -1,9 +1,13 @@
-import { MockFactory } from "../test/mockFactory"
 import mockFs from "mock-fs";
+import { MockFactory } from "../test/mockFactory";
+import { AzureBlobStorageService } from "./azureBlobStorageService";
 
 jest.mock("@azure/storage-blob");
-import { BlockBlobURL, ContainerURL, ServiceURL, Aborter, uploadFileToBlockBlob } from "@azure/storage-blob";
-import { AzureBlobStorageService } from "./azureBlobStorageService";
+jest.genMockFromModule("@azure/storage-blob")
+import { Aborter, BlockBlobURL, ContainerURL, ServiceURL, uploadFileToBlockBlob, TokenCredential } from "@azure/storage-blob";
+
+jest.mock("./loginService");
+import { AzureLoginService } from "./loginService"
 
 describe("Azure Blob Storage Service", () => {
 
@@ -18,9 +22,23 @@ describe("Azure Blob Storage Service", () => {
   const blockBlobUrl = MockFactory.createTestBlockBlobUrl(containerName, filePath);
 
   let service: AzureBlobStorageService;
+  const token = "myToken";
 
   beforeAll(() => {
+    (TokenCredential as any).mockImplementation((token: string) => {
+      token
+    });
+
     BlockBlobURL.fromContainerURL = jest.fn(() => blockBlobUrl) as any;
+    AzureLoginService.login = jest.fn(() => Promise.resolve({
+      credentials: {
+        getToken: jest.fn(() => {
+          return {
+            accessToken: token
+          }
+        })
+      }
+    } as any));
   });
 
   beforeAll(() => {
@@ -37,6 +55,11 @@ describe("Azure Blob Storage Service", () => {
     service = new AzureBlobStorageService(sls, options);
   });
 
+  it("should initialize authentication", async () => {
+    await service.initialize();
+    expect(TokenCredential).toBeCalledWith(token);
+  });
+
   it("should upload a file", async () => {
     uploadFileToBlockBlob.prototype = jest.fn();
     ContainerURL.fromServiceURL = jest.fn((serviceUrl, containerName) => (containerName as any));
diff --git a/src/services/loginService.test.ts b/src/services/loginService.test.ts
index 2e8d23c..a43e511 100644
--- a/src/services/loginService.test.ts
+++ b/src/services/loginService.test.ts
@@ -30,7 +30,8 @@ describe("Login Service", () => {
     expect(loginWithServicePrincipalSecretWithAuthResponse).toBeCalledWith(
       "azureServicePrincipalClientId",
       "azureServicePrincipalPassword",
-      "azureServicePrincipalTenantId"
+      "azureServicePrincipalTenantId",
+      undefined // would be options
     );
   });
 });
\ No newline at end of file
diff --git a/src/shared/utils.test.ts b/src/shared/utils.test.ts
index 3f37d70..8f31917 100644
--- a/src/shared/utils.test.ts
+++ b/src/shared/utils.test.ts
@@ -64,4 +64,18 @@ describe("utils", () => {
 
     expect(metadata).toEqual(expectedMetadata);
   });
-});
\ No newline at end of file
+
+  it("should create string from substrings", () => {
+    expect(
+      Utils.appendSubstrings(
+        2,
+        "abcde",
+        "fghij",
+        "klmno",
+        "pqrst",
+        "uvwxyz",
+        "ab",
+      )
+    ).toEqual("abfgklpquvab");
+  });
+});
diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts
index 2b78b48..6177bd7 100644
--- a/src/test/mockFactory.ts
+++ b/src/test/mockFactory.ts
@@ -1,21 +1,21 @@
+import { ApiContract, ApiManagementServiceResource } from "@azure/arm-apimanagement/esm/models";
+import { FunctionEnvelope, Site } from "@azure/arm-appservice/esm/models";
+import { DeploymentsListByResourceGroupResponse } from "@azure/arm-resources/esm/models";
+import { HttpHeaders, HttpOperationResponse, HttpResponse, WebResource } from "@azure/ms-rest-js";
+import { AuthResponse, LinkedSubscription, TokenCredentialsBase } from "@azure/ms-rest-nodeauth";
+import { TokenClientCredentials, TokenResponse } from "@azure/ms-rest-nodeauth/dist/lib/credentials/tokenClientCredentials";
+import { ServiceListContainersSegmentResponse } from "@azure/storage-blob/typings/lib/generated/lib/models";
+import { AxiosRequestConfig, AxiosResponse } from "axios";
 import yaml from "js-yaml";
 import Serverless from "serverless";
 import Service from "serverless/classes/Service";
 import Utils from "serverless/classes/Utils";
 import PluginManager from "serverless/lib/classes/PluginManager";
-import { HttpHeaders, HttpOperationResponse, HttpResponse, WebResource } from "@azure/ms-rest-js";
-import { LinkedSubscription, TokenCredentialsBase, AuthResponse } from "@azure/ms-rest-nodeauth";
-import { TokenResponse, TokenClientCredentials } from "@azure/ms-rest-nodeauth/dist/lib/credentials/tokenClientCredentials";
-import { ServiceListContainersSegmentResponse } from "@azure/storage-blob/typings/lib/generated/lib/models";
-import { AxiosResponse, AxiosRequestConfig } from "axios";
-import { ServerlessAzureConfig } from "../models/serverless";
-import { AzureServiceProvider, ServicePrincipalEnvVariables } from "../models/azureProvider"
-import { Logger } from "../models/generic";
 import { ApiCorsPolicy, ApiManagementConfig } from "../models/apiManagement";
-import { DeploymentsListByResourceGroupResponse } from "@azure/arm-resources/esm/models";
 import { ArmResourceTemplate } from "../models/armTemplates";
-import { FunctionEnvelope, Site } from "@azure/arm-appservice/esm/models";
-import { ApiManagementServiceResource, ApiContract } from "@azure/arm-apimanagement/esm/models";
+import { AzureServiceProvider, ServicePrincipalEnvVariables } from "../models/azureProvider";
+import { Logger } from "../models/generic";
+import { ServerlessAzureConfig } from "../models/serverless";
 
 function getAttribute(object: any, prop: string, defaultValue: any): any {
   if (object && object[prop]) {