Repository: serverless/serverless-azure-functions
Pull request #390 (2019-10-31 21:49:52)

test: Additional test coverage & general cleanup
<!--
1. Please check out and follow our Contributing Guidelines: https://github.com/serverless/serverless-azure-functions/CONTRIBUTING.md
2. Do not remove any section of the template. If something is not applicable leave it empty but leave it in the PR
3. Please follow the template, otherwise we'll have to ask you to update it and it will take longer until your PR is merged
-->

## What did you implement:

- Add tests for missing coverage of `simpleFileTokenCache`
- Rename `sls.cli.log` calls to `this.log` where possible
- Added missing tests for `invokeService` and `functionAppService`
- Updated `jest.config.js` to actually ignore files within `test` folder for coverage

<!--
Add any applicable config, commands, screenshots or other resources
to make it easy for us to verify this works. The easier you make it for us
to review a PR, the faster we can review and merge it.

Examples:
* serverless.yml - Fully functioning to easily deploy changes
* Screenshots - Showing the difference between your output and the master
* Cloud Configuration - List cloud resources and show that the correct configuration is in place
* Other - Anything else that comes to mind to help us evaluate
-->

## Todos:

_**Note: Run `npm run test:ci` to run all validation checks on proposed changes**_

- [x] Ensure there are no lint errors.  
       **Validate via `npm run lint`**  
       _Note: Some reported issues can be automatically fixed by running `npm run lint:fix`_
- [x] Write tests and confirm existing functionality is not broken.  
       **Validate via `npm test`**
- [x] Write documentation
- [x] Provide verification config / commands / resources
- [x] Enable "Allow edits from maintainers" for this PR
- [x] Update the messages below

**_Is this ready for review?:_** YES
**_Is it a breaking change?:_** NO



Patch:
diff --git a/docs/examples/apim.md b/docs/examples/apim.md
index ab6c57b..67f33c2 100644
--- a/docs/examples/apim.md
+++ b/docs/examples/apim.md
@@ -111,6 +111,54 @@ provider:
       exposeHeaders:
         - "*"
 
+    # JWT validation APIM policy
+    jwtValidate:
+      headerName: authorization
+      scheme: bearer
+      failedStatusCode: 401
+      failedErrorMessage: "Authorization token is missing or invalid"
+      openId:
+        metadataUrl: "https://path/to/openid/metadata/config"
+      audiences:
+        - "audience1"
+        - "audience2"
+      issuers:
+        - "https://path/to/openid/issuer"
+
+    # Header validation APIM policy
+    checkHeaders:
+      - headerName: x-example-header-1
+        failedStatusCode: 400
+        failedErrorMessage: Not Authorized
+        values: # List of allowed values, otherwise returns error code/message
+          - value1
+          - value2
+      - headerName: x-example-header-2
+        failedStatusCode: 403
+        failedErrorMessage: Forbidden
+        values: # List of allowed values, otherwise returns error code/message
+          - value1
+          - value2
+
+    # IP Validation APIM policies
+    ipFilters:
+      - action: allow
+        addresses: # List of allowed IP addresses
+          - 1.1.1.1
+          - 2.2.2.2
+        addressRange: # Also optionally support range of IP addresses
+          from: 1.1.1.1
+          to: 2.2.2.2
+      - action: forbid
+        addresses: # List of forbidden IP addresses
+          - 3.3.3.3
+          - 4.4.4.4
+        addressRange: # Also optionally support range of IP addresses
+          from: 3.3.3.3
+          to: 4.4.4.4
+
+
+
 plugins:
   - serverless-azure-functions
 
diff --git a/jest.config.js b/jest.config.js
index 0e3d8c7..48b1e68 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -2,7 +2,7 @@ module.exports = {
   "collectCoverageFrom": [
     "src/**/*.{js,jsx,ts,tsx}",
     "!src/**/*.d.ts",
-    "!/src/test/"
+    "!src/test/*.ts"
   ],
   "testEnvironment": "node",
   "transform": {
diff --git a/package-lock.json b/package-lock.json
index ff27f52..2c9d9f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "serverless-azure-functions",
-  "version": "1.0.2-15",
+  "version": "1.0.2-17",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
diff --git a/package.json b/package.json
index dd92572..ed8cf25 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "serverless-azure-functions",
-  "version": "1.0.2-15",
+  "version": "1.0.2-17",
   "description": "Provider plugin for the Serverless Framework v1.x which adds support for Azure Functions.",
   "license": "MIT",
   "main": "./lib/index.js",
diff --git a/src/models/apiManagement.ts b/src/models/apiManagement.ts
index d21e21d..e445e54 100644
--- a/src/models/apiManagement.ts
+++ b/src/models/apiManagement.ts
@@ -16,6 +16,10 @@ export interface ApiManagementConfig {
   jwtValidate?: ApiJwtValidatePolicy;
   /** The API's IP Filter policy */
   ipFilter?: ApiIpFilterPolicy;
+  /** The API's IP Filter policies */
+  ipFilters?: ApiIpFilterPolicy[];
+  /** The API's header policies */
+  checkHeaders?: ApiCheckHeaderPolicy[];
   /** The pricing SKU for the APIM instance */
   sku?: {
     /** The SKU name, (consumption | developer | basic | standard | premium) */
@@ -102,7 +106,7 @@ export interface ApiJwtClaim {
 }
 
 /**
- * A IP Filter validation policy
+ * An IP Filter validation policy
  */
 export interface ApiIpFilterPolicy {
   /** Whether the policy should allow or forbid the address specification */
@@ -114,3 +118,19 @@ export interface ApiIpFilterPolicy {
     to: string;
   };
 }
+
+/**
+ * A header validation policy
+ */
+export interface ApiCheckHeaderPolicy {
+  /** The name of the HTTP header that to validate */
+  headerName: string;
+  /** The HTTP status code to return for a failed response */
+  failedStatusCode?: number;
+  /** The error message to return for a failed response */
+  failedErrorMessage?: string;
+  /** Performs a case insensitive comparison to the allowed values */
+  ignoreCase?: boolean;
+  /** List of allowed values to compare against */
+  values: string[];
+}
diff --git a/src/plugins/apim/azureApimPlugin.ts b/src/plugins/apim/azureApimPlugin.ts
index 500f441..c43ad7f 100644
--- a/src/plugins/apim/azureApimPlugin.ts
+++ b/src/plugins/apim/azureApimPlugin.ts
@@ -17,7 +17,7 @@ export class AzureApimPlugin extends AzureBasePlugin {
       return Promise.resolve();
     }
 
-    this.serverless.cli.log("Starting APIM service deployment");
+    this.log("Starting APIM service deployment");
 
     const apimService = new ApimService(this.serverless, this.options);
     await apimService.deploy();
diff --git a/src/plugins/deploy/azureDeployPlugin.ts b/src/plugins/deploy/azureDeployPlugin.ts
index 8d7cf3e..7922f82 100644
--- a/src/plugins/deploy/azureDeployPlugin.ts
+++ b/src/plugins/deploy/azureDeployPlugin.ts
@@ -93,7 +93,7 @@ export class AzureDeployPlugin extends AzureBasePlugin<AzureLoginOptions> {
       return Promise.resolve();
     }
 
-    this.serverless.cli.log("Starting APIM service deployment");
+    this.log("Starting APIM service deployment");
 
     const apimService = new ApimService(this.serverless, this.options);
     await apimService.deploy();
diff --git a/src/plugins/login/azureLoginPlugin.ts b/src/plugins/login/azureLoginPlugin.ts
index 137b387..e4f70fd 100644
--- a/src/plugins/login/azureLoginPlugin.ts
+++ b/src/plugins/login/azureLoginPlugin.ts
@@ -36,7 +36,7 @@ export class AzureLoginPlugin extends AzureBasePlugin<AzureLoginOptions> {
       }
       subId = subId || authResult.subscriptions[0].id;
       this.serverless.variables["subscriptionId"] = subId;
-      this.serverless.cli.log(`Using subscription ID: ${subId}`);
+      this.log(`Using subscription ID: ${subId}`);
     }
     catch (e) {
       this.log("Error logging into azure");
diff --git a/src/plugins/login/utils/simpleFileTokenCache.ts b/src/plugins/login/utils/simpleFileTokenCache.ts
index 601e167..f10adb7 100644
--- a/src/plugins/login/utils/simpleFileTokenCache.ts
+++ b/src/plugins/login/utils/simpleFileTokenCache.ts
@@ -57,18 +57,13 @@ export class SimpleFileTokenCache implements adal.TokenCache {
 
   public addSubs(subscriptions: any) {
     this.subscriptions.push(...subscriptions);
-    this.subscriptions = this.subscriptions.reduce((accumulator , current) => {
-      const x = accumulator .find(item => item.id === current.id);
-      if (!x) {
-        return accumulator .concat([current]);
-      } else {
-        return accumulator ;
-      }
+    this.subscriptions = this.subscriptions.reduce((accumulator, current) => {
+      const x = accumulator.find(item => item.id === current.id);
+      return (!x) ? accumulator.concat([current]) : accumulator;
     }, []);
     this.save();
   }
 
-
   public clear(callback: any) {
     this.entries = [];
     this.subscriptions = [];
diff --git a/src/services/apimPolicyBuilder.ts b/src/services/apimPolicyBuilder.ts
index c86f1c6..404dead 100644
--- a/src/services/apimPolicyBuilder.ts
+++ b/src/services/apimPolicyBuilder.ts
@@ -1,7 +1,9 @@
-import { ApiJwtValidatePolicy, ApiJwtClaim, ApiCorsPolicy, ApiIpFilterPolicy } from "../models/apiManagement";
+import { ApiJwtValidatePolicy, ApiJwtClaim, ApiCorsPolicy, ApiIpFilterPolicy, ApiCheckHeaderPolicy } from "../models/apiManagement";
 import { Guard } from "../shared/guard";
 import { Builder, Parser } from "xml2js";
 
+declare type PolicyType = "inbound" | "outbound" | "backend" | "on-error";
+
 /**
  * APIM Policy build that can be used to build polices for APIs, Backends & operations
  */
@@ -13,7 +15,7 @@ export class ApimPolicyBuilder {
   }
 
   /**
-   * Parses an APIM xml policy and loads it into a Policy Bulder
+   * Parses an APIM xml policy and loads it into a Policy Builder
    * @param xml The xml policy
    */
   public static async parse(xml: string): Promise<ApimPolicyBuilder> {
@@ -80,19 +82,21 @@ export class ApimPolicyBuilder {
   public ipFilter(ipConfig: ApiIpFilterPolicy): ApimPolicyBuilder {
     Guard.null(ipConfig, "ipConfig");
 
-    const element = {
+    const policy = {
       $: { action: ipConfig.action },
       addressRange: ipConfig.addressRange,
       address: ipConfig.addresses
     }
 
-    Object.keys(element).forEach((key) => {
-      if (!element[key]) {
-        delete element[key];
-      }
-    });
+    ApimPolicyBuilder.cleanElement(policy);
 
-    this.policyRoot.policies.inbound[0]["ip-filter"] = [element];
+    this.updatePolicy(
+      "inbound",
+      "ip-filter",
+      policy,
+      // We are keying the ipFilter policy by action
+      (policy) => policy.$.action === ipConfig.action
+    );
 
     return this;
   }
@@ -109,8 +113,9 @@ export class ApimPolicyBuilder {
     const audiences = jwtPolicy.audiences ? [jwtPolicy.audiences.map((audience) => ({ audience }))] : null;
     const issuers = jwtPolicy.issuers ? [jwtPolicy.issuers.map((issuer) => ({ issuer }))] : null;
     const oidConfig = jwtPolicy.openId && jwtPolicy.openId.metadataUrl ? [{ $: { url: jwtPolicy.openId.metadataUrl } }] : null;
-    const claims = jwtPolicy.requiredClaims ? [jwtPolicy.requiredClaims.map(this.createClaimElement)] : null;
+    const claims = jwtPolicy.requiredClaims ? [jwtPolicy.requiredClaims.map(ApimPolicyBuilder.createClaimElement)] : null;
 
+    // Maps yaml config names to xml attribute names
     const attributeMap = {
       headerName: "header-name",
       tokenValue: "token-value",
@@ -124,15 +129,9 @@ export class ApimPolicyBuilder {
       outputTokenVariableName: "output-token-variable-name"
     };
 
-    const attributes = {};
-
-    Object.keys(attributeMap).forEach((key) => {
-      if (jwtPolicy[key]) {
-        attributes[attributeMap[key]] = jwtPolicy[key];
-      }
-    });
+    const attributes = ApimPolicyBuilder.createAttributes(attributeMap, jwtPolicy);
 
-    const elements = {
+    const policy = {
       $: attributes,
       "openid-config": oidConfig,
       "issuer-signing-keys": signingKeys,
@@ -142,13 +141,37 @@ export class ApimPolicyBuilder {
       "required-claims": claims
     };
 
-    Object.keys(elements).forEach((key) => {
-      if (!elements[key]) {
-        delete elements[key];
-      }
-    });
+    ApimPolicyBuilder.cleanElement(policy);
+    this.policyRoot.policies.inbound[0]["validate-jwt"] = [policy];
 
-    this.policyRoot.policies.inbound[0]["validate-jwt"] = [elements];
+    return this;
+  }
+
+  public checkHeader(checkHeaderPolicy: ApiCheckHeaderPolicy): ApimPolicyBuilder {
+    Guard.null(checkHeaderPolicy, "checkHeaderPolicy");
+
+    // Maps yaml config names to xml attribute names
+    const attributeMap = {
+      headerName: "name",
+      failedStatusCode: "failed-check-httpcode",
+      failedErrorMessage: "failed-check-error-message",
+      ignoreCase: "ignore-case"
+    };
+
+    const attributes = ApimPolicyBuilder.createAttributes(attributeMap, checkHeaderPolicy);
+
+    const policy = {
+      $: attributes,
+      value: checkHeaderPolicy.values
+    };
+
+    this.updatePolicy(
+      "inbound",
+      "check-header",
+      policy,
+      // We are keying the check-header policy by header name
+      (policy) => policy.$.name === checkHeaderPolicy.headerName
+    );
 
     return this;
   }
@@ -168,11 +191,69 @@ export class ApimPolicyBuilder {
     }
   }
 
+  /**
+   * Updates the matching policy or appends a new policy
+   * @param policyType The policy type (inbound | outbound | backend | on-error)
+   * @param policyName The xml element name of the policy
+   * @param newPolicy The new policy XML object
+   * @param predicate The condition to check for existing policy
+   */
+  private updatePolicy(policyType: PolicyType, policyName: string, newPolicy: any, predicate: (policy: any) => boolean): void {
+    const existing = this.policyRoot.policies[policyType][0][policyName];
+
+    // Add new policy to empty list
+    if (!existing) {
+      this.policyRoot.policies[policyType][0][policyName] = [newPolicy];
+      return;
+    }
+
+    // Find and replace the first matching policy
+    for (let i = 0; i < existing.length; i++) {
+      const policy = existing[i];
+      if (predicate(policy)) {
+        existing[i] = newPolicy;
+        return;
+      }
+    }
+
+    // Append new policy if not found
+    existing.push(newPolicy);
+  }
+
+  /**
+   * Creates a map of XML attributes for the APIM policy
+   * @param attributeMap The attribute mapping from yaml -> xml
+   * @param policy The APIM yaml policy
+   */
+  private static createAttributes(attributeMap: any, policy: any): any {
+    const attributes = {};
+
+    Object.keys(attributeMap).forEach((key) => {
+      if (policy[key]) {
+        attributes[attributeMap[key]] = policy[key];
+      }
+    });
+
+    return attributes;
+  }
+
+  /**
+   * Cleans an element so that when its converted to XML it doesn't generate empty tags
+   * @param element The pseudo XML element to clean
+   */
+  private static cleanElement(element: any) {
+    Object.keys(element).forEach((key) => {
+      if (!element[key]) {
+        delete element[key];
+      }
+    });
+  }
+
   /**
    * Create a claim element for a JWT validation policy
    * @param claim The JWT claim
    */
-  private createClaimElement(claim: ApiJwtClaim) {
+  private static createClaimElement(claim: ApiJwtClaim) {
     return {
       claim: {
         $: { name: claim.name, match: claim.match, separator: claim.separator },
diff --git a/src/services/apimService.ts b/src/services/apimService.ts
index 3c243ca..c3b5a7d 100644
--- a/src/services/apimService.ts
+++ b/src/services/apimService.ts
@@ -361,9 +361,23 @@ export class ApimService extends BaseService {
       requireUpdate = true;
     }
 
+    // Support backwards compatibility for single IP filter
     if (this.apimConfig.ipFilter) {
-      builder.ipFilter(this.apimConfig.ipFilter);
-      requireUpdate = true;
+      this.apimConfig.ipFilters = [this.apimConfig.ipFilter];
+    }
+
+    if (this.apimConfig.ipFilters) {
+      this.apimConfig.ipFilters.forEach((policy) => {
+        builder.ipFilter(policy);
+        requireUpdate = true;
+      });
+    }
+
+    if (this.apimConfig.checkHeaders) {
+      this.apimConfig.checkHeaders.forEach((policy) => {
+        builder.checkHeader(policy);
+        requireUpdate = true;
+      });
     }
 
     if (requireUpdate) {
diff --git a/src/services/armService.ts b/src/services/armService.ts
index f64f7e4..837f56a 100644
--- a/src/services/armService.ts
+++ b/src/services/armService.ts
@@ -101,6 +101,8 @@ export class ArmService extends BaseService {
       return;
     }
 
+    deployment.parameters = deployment.parameters || {};
+    
     for (const key of Object.keys(deployment.parameters)) {
       if (!deployment.parameters[key].value) {
         delete deployment.parameters[key];
@@ -160,6 +162,9 @@ export class ArmService extends BaseService {
 
     const paramsNormalizer = (params: ArmParameters): ArmParameters => {
       const normalized = {};
+      if (!params) {
+        return normalized;
+      }
       const keys = Object.keys(params);
       for (const key of keys) {
         const original = params[key];
@@ -207,6 +212,8 @@ export class ArmService extends BaseService {
    */
   private mergeDefaultParams(parameters: ArmParameters, defaultParameters: ArmParameters): ArmParameters {
     const mergedParams: ArmParameters = {}
+    parameters = parameters || {};
+    defaultParameters = defaultParameters || {};
     Object.keys(defaultParameters).forEach((key) => {
       const defaultParam = defaultParameters[key];
       const param = parameters[key];
@@ -227,7 +234,7 @@ export class ArmService extends BaseService {
     // added to the ARM template used in the deployment.
     const environmentVariables = this.config.provider.environment;
     if (environmentVariables) {
-      this.serverless.cli.log("-> Merging environment configuration");
+      this.log("-> Merging environment configuration");
 
       // This is a json path expression
       // Learn more @ https://goessner.net/articles/JsonPath/index.html#e2
diff --git a/src/services/funcService.ts b/src/services/funcService.ts
index 580f155..dbf5123 100644
--- a/src/services/funcService.ts
+++ b/src/services/funcService.ts
@@ -16,7 +16,7 @@ export class FuncService extends BaseService {
       return;
     }
     if (this.exists(functionName)) {
-      this.serverless.cli.log(`Function ${functionName} already exists`);
+      this.log(`Function ${functionName} already exists`);
       return;
     }
     this.createHandler(functionName);
diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts
index 9853302..3cca8af 100644
--- a/src/services/functionAppService.ts
+++ b/src/services/functionAppService.ts
@@ -29,9 +29,9 @@ export class FunctionAppService extends BaseService {
   public async get(): Promise<Site> {
     const response: any = await this.webClient.webApps.get(this.resourceGroup, FunctionAppResource.getResourceName(this.config));
     if (response.error && (response.error.code === "ResourceNotFound" || response.error.code === "ResourceGroupNotFound")) {
-      this.serverless.cli.log(this.resourceGroup);
-      this.serverless.cli.log(FunctionAppResource.getResourceName(this.config));
-      this.serverless.cli.log(JSON.stringify(response));
+      this.log(this.resourceGroup);
+      this.log(FunctionAppResource.getResourceName(this.config));
+      this.log(JSON.stringify(response));
       return null;
     }
 
diff --git a/src/services/invokeService.ts b/src/services/invokeService.ts
index dbb4b52..3a694b9 100644
--- a/src/services/invokeService.ts
+++ b/src/services/invokeService.ts
@@ -27,7 +27,7 @@ export class InvokeService extends BaseService {
     const functionObject = this.configService.getFunctionConfig()[functionName];
     /* accesses the admin key */
     if (!functionObject) {
-      this.serverless.cli.log(`Function ${functionName} does not exist`);
+      this.log(`Function ${functionName} does not exist`);
       return;
     }
 
diff --git a/src/services/packageService.ts b/src/services/packageService.ts
index c92029c..10c7da9 100644
--- a/src/services/packageService.ts
+++ b/src/services/packageService.ts
@@ -51,7 +51,7 @@ export class PackageService extends BaseService {
       }
     });
 
-    this.serverless.cli.log("Copying files for webpack");
+    this.log("Copying files for webpack");
     filesToCopy.forEach((filePath) => {
       const destinationPath = path.join(".webpack", "service", filePath);
       const destinationDirectory = path.dirname(destinationPath);
@@ -59,7 +59,7 @@ export class PackageService extends BaseService {
         fs.mkdirSync(destinationDirectory);
       }
       fs.copyFileSync(filePath, destinationPath);
-      this.serverless.cli.log(`-> ${destinationPath}`);
+      this.log(`-> ${destinationPath}`);
     });
 
     return Promise.resolve();


Test patch:
diff --git a/src/plugins/func/azureFuncPlugin.test.ts b/src/plugins/func/azureFuncPlugin.test.ts
index 9869dbd..5b01606 100644
--- a/src/plugins/func/azureFuncPlugin.test.ts
+++ b/src/plugins/func/azureFuncPlugin.test.ts
@@ -59,7 +59,7 @@ describe("Azure Func Plugin", () => {
       const plugin = new AzureFuncPlugin(sls, options);
       await invokeHook(plugin, "func:add:add");
 
-      expect(sls.cli.log).toBeCalledWith("Function hello already exists");
+      expect(sls.cli.log).lastCalledWith("Function hello already exists", undefined, undefined);
     });
 
     it("creates function handler and updates serverless.yml", async () => {
diff --git a/src/plugins/login/utils/simpleFileTokenCache.test.ts b/src/plugins/login/utils/simpleFileTokenCache.test.ts
index e9383e4..598b2e7 100644
--- a/src/plugins/login/utils/simpleFileTokenCache.test.ts
+++ b/src/plugins/login/utils/simpleFileTokenCache.test.ts
@@ -13,13 +13,15 @@ describe("Simple File Token Cache", () => {
     subscriptions: []
   };
 
+  beforeEach(() => {
+    mockFs();
+  });
+
   afterEach(() => {
     mockFs.restore();
   });
 
   it("Creates a load file on creation if none", () => {
-    mockFs();
-
     const writeFileSpy = jest.spyOn(fs, "writeFileSync");
     new SimpleFileTokenCache(tokenFilePath);
 
@@ -38,8 +40,6 @@ describe("Simple File Token Cache", () => {
   it("Create .azure default directory if it doesn't exist", () => {
     const expected = path.join(os.homedir(), ".azure");
 
-    mockFs();
-
     const writeFileSpy = jest.spyOn(fs, "writeFileSync");
     writeFileSpy.mockImplementation(() => undefined);
 
@@ -77,13 +77,12 @@ describe("Simple File Token Cache", () => {
   });
 
   it("Saves to file after token is added", () => {
-    mockFs();
-
     const writeFileSpy = jest.spyOn(fs, "writeFileSync");
     const tokenCache = new SimpleFileTokenCache(tokenFilePath);
     const testEntries = MockFactory.createTestTokenCacheEntries();
-
-    tokenCache.add(testEntries);
+    
+    const callback = jest.fn();
+    tokenCache.add(testEntries, callback);
 
     const expected = {
       entries: testEntries,
@@ -95,12 +94,11 @@ describe("Simple File Token Cache", () => {
       tokenFilePath,
       JSON.stringify(expected)
     );
+    expect(callback).toBeCalled();
     writeFileSpy.mockRestore();
   });
 
   it("Saves to file after subscription is added", () => {
-    mockFs();
-
     const writeFileSpy = jest.spyOn(fs, "writeFileSync");
     const testFileCache = new SimpleFileTokenCache(tokenFilePath);
     const testSubs = MockFactory.createTestSubscriptions();
@@ -153,7 +151,8 @@ describe("Simple File Token Cache", () => {
     const testEntries = MockFactory.createTestTokenCacheEntries();
 
     testFileCache.addSubs(testSubs);
-    testFileCache.remove(testEntries);
+    const callback = jest.fn();
+    testFileCache.remove(testEntries, callback);
 
     const expected = {
       entries: [],
@@ -164,6 +163,7 @@ describe("Simple File Token Cache", () => {
       tokenFilePath,
       JSON.stringify(expected)
     );
+    expect(callback).toBeCalled();
     writeFileSpy.mockRestore();
   });
 
@@ -182,4 +182,33 @@ describe("Simple File Token Cache", () => {
     expect(cb).toBeCalledWith(null, result);
     expect(result).toEqual([]);
   });
+
+  it("lists subscriptions", () => {
+    const testFileCache = new SimpleFileTokenCache(tokenFilePath);
+    const testSubs = MockFactory.createTestSubscriptions();
+    testFileCache.addSubs(testSubs);
+    expect(testFileCache.listSubscriptions()).toEqual(testSubs);
+  });
+
+  it("clears cache", () => {
+    const writeFileSpy = jest.spyOn(fs, "writeFileSync");
+
+    const testFileCache = new SimpleFileTokenCache(tokenFilePath);
+    const testSubs = MockFactory.createTestSubscriptions();
+    testFileCache.addSubs(testSubs);
+
+    const testEntries = MockFactory.createTestTokenCacheEntries();
+    testFileCache.add(testEntries);
+
+    expect(testFileCache.isEmpty()).toBe(false);
+    expect(testFileCache.listSubscriptions()).toEqual(testSubs);
+    
+    const callback = jest.fn();
+    testFileCache.clear(callback);
+    
+    expect(callback).toBeCalled();
+    expect(testFileCache.listSubscriptions()).toEqual([]);
+    expect(testFileCache.isEmpty()).toBe(true);
+    expect(writeFileSpy).toBeCalled();
+  });
 });
diff --git a/src/services/apimPolicyBuilder.test.ts b/src/services/apimPolicyBuilder.test.ts
index c5b30e0..cc7df5d 100644
--- a/src/services/apimPolicyBuilder.test.ts
+++ b/src/services/apimPolicyBuilder.test.ts
@@ -1,7 +1,7 @@
 import { MockFactory } from "../test/mockFactory";
 import { ApimPolicyBuilder } from "./apimPolicyBuilder";
 import fs from "fs";
-import { ApiIpFilterPolicy } from "../models/apiManagement";
+import { ApiIpFilterPolicy, ApiCheckHeaderPolicy } from "../models/apiManagement";
 
 declare global {
   interface String {
@@ -100,8 +100,8 @@ describe("APIM PolicyBuilder", () => {
     expect(actual).toEqual(expected);
   });
 
-  it("can create an IP filter API policy", () => {
-    const expected = fs.readFileSync(`${process.cwd()}/src/test/policies/ip-filter.xml`)
+  it("can create an IP filter API allow policy", () => {
+    const expected = fs.readFileSync(`${process.cwd()}/src/test/policies/ip-filter-allow.xml`)
       .toString()
       .trim()
       .cleanXml();
@@ -126,4 +126,104 @@ describe("APIM PolicyBuilder", () => {
 
     expect(actual).toEqual(expected);
   });
+
+  it("can create an IP filter API forbid policy", () => {
+    const expected = fs.readFileSync(`${process.cwd()}/src/test/policies/ip-filter-forbid.xml`)
+      .toString()
+      .trim()
+      .cleanXml();
+
+    const ipPolicy: ApiIpFilterPolicy = {
+      action: "forbid",
+      addressRange: {
+        from: "1.1.1.1",
+        to: "1.1.1.255"
+      },
+      addresses: [
+        "2.2.2.2",
+        "3.3.3.3"
+      ]
+    };
+
+    const policyBuilder = new ApimPolicyBuilder();
+    const actual = policyBuilder
+      .ipFilter(ipPolicy)
+      .build()
+      .cleanXml();
+
+    expect(actual).toEqual(expected);
+  });
+
+  it("can create a policy with allow and forbid constraints", () => {
+    const expected = fs.readFileSync(`${process.cwd()}/src/test/policies/ip-filter-combined.xml`)
+      .toString()
+      .trim()
+      .cleanXml();
+
+    const allowPolicy: ApiIpFilterPolicy = {
+      action: "allow",
+      addresses: ["1.1.1.1"]
+    };
+    const forbidPolicy: ApiIpFilterPolicy = {
+      action: "forbid",
+      addresses: ["2.2.2.2"]
+    };
+
+    const policyBuilder = new ApimPolicyBuilder();
+    const actual = policyBuilder
+      .ipFilter(allowPolicy)
+      .ipFilter(forbidPolicy)
+      .build()
+      .cleanXml();
+
+    expect(actual).toEqual(expected);
+  });
+
+  it("can create a check header policy", () => {
+    const expected = fs.readFileSync(`${process.cwd()}/src/test/policies/check-header.xml`)
+      .toString()
+      .trim()
+      .cleanXml();
+
+    const checkHeaderPolicy: ApiCheckHeaderPolicy = {
+      headerName: "authorization",
+      failedStatusCode: 401,
+      failedErrorMessage: "The authorization header is either missing or invalid",
+      values: ["value1", "value2", "value3"],
+      ignoreCase: true
+    };
+
+    const policyBuilder = new ApimPolicyBuilder();
+    const actual = policyBuilder
+      .checkHeader(checkHeaderPolicy)
+      .build()
+      .cleanXml();
+
+    expect(actual).toEqual(expected);
+  });
+
+  it("can append a new check header policy", async () => {
+    const expected = fs.readFileSync(`${process.cwd()}/src/test/policies/append-check-header.xml`)
+      .toString()
+      .trim()
+      .cleanXml();
+
+    const policyXml = fs.readFileSync(`${process.cwd()}/src/test/policies/check-header.xml`).toString();
+    const policyBuilder = await ApimPolicyBuilder.parse(policyXml);
+
+    const anotherPolicy: ApiCheckHeaderPolicy = {
+      headerName: "foo",
+      values: ["bar"],
+      failedStatusCode: 400,
+      failedErrorMessage: "Bad Request",
+      ignoreCase: true
+    };
+
+    const actual = policyBuilder
+      .checkHeader(anotherPolicy)
+      .build()
+      .cleanXml();
+
+    expect(actual).toEqual(expected);
+  });
 });
diff --git a/src/services/apimService.test.ts b/src/services/apimService.test.ts
index 49f7458..afa89d2 100644
--- a/src/services/apimService.test.ts
+++ b/src/services/apimService.test.ts
@@ -21,7 +21,7 @@ import {
   ApiPolicyGetResponse
 } from "@azure/arm-apimanagement/esm/models";
 import { AzureNamingService } from "./namingService";
-import { ApiManagementConfig, ApiIpFilterPolicy } from "../models/apiManagement";
+import { ApiManagementConfig, ApiIpFilterPolicy, ApiCheckHeaderPolicy } from "../models/apiManagement";
 import { ApimPolicyBuilder } from "./apimPolicyBuilder";
 
 describe("APIM Service", () => {
@@ -388,6 +388,61 @@ describe("APIM Service", () => {
           }
         );
       });
+
+      it("deploys API IP filter policies when defined within configuration", async () => {
+        jest.spyOn(ApimPolicyBuilder.prototype, "ipFilter");
+
+        const ipFilterPolicy: ApiIpFilterPolicy = {
+          action: "allow",
+          addresses: [
+            "1.1.1.1",
+            "2.2.2.2"
+          ]
+        }
+
+        serverless.service.provider["apim"]["ipFilters"] = [ipFilterPolicy];
+
+        const apimService = new ApimService(serverless);
+        await apimService.deploy();
+
+        expect(ApimPolicyBuilder.prototype.ipFilter).toBeCalledWith(ipFilterPolicy);
+        expect(ApiPolicy.prototype.createOrUpdate).toBeCalledWith(
+          resourceGroupName,
+          serviceName,
+          apiName,
+          {
+            format: "rawxml",
+            value: expect.stringContaining("ip-filter"),
+          }
+        );
+      });
+
+      it("deploys API check header policies when defined within configuration", async () => {
+        jest.spyOn(ApimPolicyBuilder.prototype, "checkHeader");
+
+        const checkHeaderPolicy: ApiCheckHeaderPolicy = {
+          headerName: "authorization",
+          failedStatusCode: 401,
+          failedErrorMessage: "Not Authorized",
+          values: ["value1"]
+        };
+
+        serverless.service.provider["apim"]["checkHeaders"] = [checkHeaderPolicy];
+
+        const apimService = new ApimService(serverless);
+        await apimService.deploy();
+
+        expect(ApimPolicyBuilder.prototype.checkHeader).toBeCalledWith(checkHeaderPolicy);
+        expect(ApiPolicy.prototype.createOrUpdate).toBeCalledWith(
+          resourceGroupName,
+          serviceName,
+          apiName,
+          {
+            format: "rawxml",
+            value: expect.stringContaining("check-header"),
+          }
+        );
+      });
     });
 
     it("returns null when APIM is not configured", async () => {
diff --git a/src/services/armService.test.ts b/src/services/armService.test.ts
index d2baa30..c0283cc 100644
--- a/src/services/armService.test.ts
+++ b/src/services/armService.test.ts
@@ -167,7 +167,16 @@ describe("Arm Service", () => {
         template: MockFactory.createTestArmTemplate()
       };
       await service.deployTemplate(deployment);
-      expect(Deployments.prototype.createOrUpdate).not.toBeCalled()
+      expect(Deployments.prototype.createOrUpdate).not.toBeCalled();
+    });
+
+    it("Does not crash if deployment parameters are undefined", async () => {
+      const deployment: ArmDeployment = {
+        parameters: undefined,
+        template: MockFactory.createTestArmTemplate(),
+      };
+      await service.deployTemplate(deployment);
+      expect(Deployments.prototype.createOrUpdate).toBeCalled();
     });
 
     it("Does not deploy if identity is only difference between deployments", async () => {
diff --git a/src/services/funcService.test.ts b/src/services/funcService.test.ts
index fd0d9d1..c36fdd7 100644
--- a/src/services/funcService.test.ts
+++ b/src/services/funcService.test.ts
@@ -52,7 +52,7 @@ describe("Azure Func Service", () => {
       const service = createService(sls, options);
       await service.add();
 
-      expect(sls.cli.log).toBeCalledWith("Function hello already exists");
+      expect(sls.cli.log).toBeCalledWith("Function hello already exists", undefined, undefined);
     });
 
     it("creates function handler and updates serverless.yml", async () => {
diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts
index 4ef79f6..248efe5 100644
--- a/src/services/functionAppService.test.ts
+++ b/src/services/functionAppService.test.ts
@@ -33,6 +33,7 @@ describe("Function App Service", () => {
   const deleteFunctionMessage = "delete function success";
   const functions = MockFactory.createTestSlsFunctionConfig();
   const functionsResponse = MockFactory.createTestFunctionsResponse(functions);
+  const getFunctionResponse = "hello function";
 
   const baseUrl = "https://management.azure.com"
   const masterKeyUrl = `https://${app.defaultHostName}/admin/host/systemkeys/_master`;
@@ -43,6 +44,8 @@ describe("Function App Service", () => {
   const listFunctionsUrl = `${baseUrl}${app.id}/functions?api-version=2016-08-01`;
   const appSettingsUrl = `${baseUrl}/subscriptions/${subscriptionId}/resourceGroups/${config.provider.resourceGroup}` +
     `/providers/Microsoft.Web/sites/${app.name}/config/appsettings?api-version=2016-08-01`;
+  // "https://management.azure.comappId/functions/hello?api-version=2016-08-01"
+  const getFunctionUrl = `${baseUrl}${app.id}/functions/hello?api-version=2016-08-01`
 
 
   const appSettings = {
@@ -65,6 +68,8 @@ describe("Function App Service", () => {
     axiosMock.onPut(appSettingsUrl).reply(200, appSettings)
     // List Functions
     axiosMock.onGet(listFunctionsUrl).reply(200, { value: functionsResponse });
+    // Get Function
+    axiosMock.onGet(getFunctionUrl).reply(200, { value: getFunctionResponse })
     // Delete Function
     for (const funcName of Object.keys(functions)) {
       axiosMock.onDelete(`${baseUrl}${app.id}/functions/${funcName}?api-version=2016-08-01`)
@@ -191,6 +196,28 @@ describe("Function App Service", () => {
     expect(call[2]).toEqual(30000);
   });
 
+  it("gets function with retry", async () => {
+    const originalRunWithRetry = Utils.runWithRetry;
+    Utils.runWithRetry = jest.fn(() => {
+      return {
+        data: {
+          value: []
+        }
+      }
+    }) as any;
+
+    const service = createService();
+    await service.getFunction(app, "hello");
+    const runWithRetryCalls = (Utils.runWithRetry as any).mock.calls;
+    Utils.runWithRetry = originalRunWithRetry;
+    expect(runWithRetryCalls).toHaveLength(1);
+    const call = runWithRetryCalls[0];
+    const response = await call[0]();
+    expect(response.data.value).toEqual(getFunctionResponse);
+    expect(call[1]).toEqual(30);
+    expect(call[2]).toEqual(30000);
+  });
+
   describe("Deployments", () => {
     const expectedDeployment: ArmDeployment = {
       parameters: {},
@@ -410,7 +437,7 @@ describe("Function App Service", () => {
         return {
           properties: appSettings
         }
-      });
+      }) as any;
       AzureBlobStorageService.prototype.generateBlobSasTokenUrl = jest.fn(() => Promise.resolve(sasUrl));
     });
 
diff --git a/src/services/invokeService.test.ts b/src/services/invokeService.test.ts
index f4b6b8a..a6c6183 100644
--- a/src/services/invokeService.test.ts
+++ b/src/services/invokeService.test.ts
@@ -1,12 +1,13 @@
 import axios from "axios";
 import MockAdapter from "axios-mock-adapter";
+import configConstants from "../config";
 import { MockFactory } from "../test/mockFactory";
+import { FunctionAppService } from "./functionAppService";
 import { InvokeService } from "./invokeService";
+
 jest.mock("@azure/arm-appservice")
 jest.mock("@azure/arm-resources")
 jest.mock("./functionAppService")
-import { FunctionAppService } from "./functionAppService";
-import configConstants from "../config";
 
 describe("Invoke Service ", () => {
   const app = MockFactory.createTestSite();
@@ -81,4 +82,11 @@ describe("Invoke Service ", () => {
     expect(FunctionAppService.prototype.get).not.toBeCalled();
     expect(FunctionAppService.prototype.getMasterKey).not.toBeCalled();
   });
+
+  it("Does not try to invoke a non-existent function", async () => {
+    const service = new InvokeService(sls, options);
+    const fakeName = "fakeFunction";
+    await service.invoke("GET", fakeName);
+    expect(sls.cli.log).lastCalledWith(`Function ${fakeName} does not exist`, undefined, undefined)
+  });
 });
diff --git a/src/test/policies/append-check-header.xml b/src/test/policies/append-check-header.xml
new file mode 100644
index 0000000..aed12f9
--- /dev/null
+++ b/src/test/policies/append-check-header.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<policies>
+  <inbound>
+    <base/>
+    <check-header name="authorization" failed-check-httpcode="401" failed-check-error-message="The authorization header is either missing or invalid" ignore-case="true">
+      <value>value1</value>
+      <value>value2</value>
+      <value>value3</value>
+    </check-header>
+    <check-header name="foo" failed-check-httpcode="400" failed-check-error-message="Bad Request" ignore-case="true">
+      <value>bar</value>
+    </check-header>
+  </inbound>
+  <backend>
+    <base/>
+  </backend>
+  <outbound>
+    <base/>
+  </outbound>
+  <on-error>
+    <base/>
+  </on-error>
+</policies>
diff --git a/src/test/policies/check-header.xml b/src/test/policies/check-header.xml
new file mode 100644
index 0000000..1f2bdbd
--- /dev/null
+++ b/src/test/policies/check-header.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<policies>
+  <inbound>
+    <base/>
+    <check-header name="authorization" failed-check-httpcode="401" failed-check-error-message="The authorization header is either missing or invalid" ignore-case="true">
+      <value>value1</value>
+      <value>value2</value>
+      <value>value3</value>
+    </check-header>
+  </inbound>
+  <backend>
+    <base/>
+  </backend>
+  <outbound>
+    <base/>
+  </outbound>
+  <on-error>
+    <base/>
+  </on-error>
+</policies>
diff --git a/src/test/policies/ip-filter.xml b/src/test/policies/ip-filter-allow.xml
similarity index 100%
rename from src/test/policies/ip-filter.xml
rename to src/test/policies/ip-filter-allow.xml
diff --git a/src/test/policies/ip-filter-combined.xml b/src/test/policies/ip-filter-combined.xml
new file mode 100644
index 0000000..22765e8
--- /dev/null
+++ b/src/test/policies/ip-filter-combined.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<policies>
+  <inbound>
+    <base/>
+    <ip-filter action="allow">
+      <address>1.1.1.1</address>
+    </ip-filter>
+    <ip-filter action="forbid">
+      <address>2.2.2.2</address>
+    </ip-filter>
+  </inbound>
+  <backend>
+    <base/>
+  </backend>
+  <outbound>
+    <base/>
+  </outbound>
+  <on-error>
+    <base/>
+  </on-error>
+</policies>
diff --git a/src/test/policies/ip-filter-forbid.xml b/src/test/policies/ip-filter-forbid.xml
new file mode 100644
index 0000000..981f7d3
--- /dev/null
+++ b/src/test/policies/ip-filter-forbid.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<policies>
+  <inbound>
+    <base/>
+    <ip-filter action="forbid">
+      <addressRange>
+        <from>1.1.1.1</from>
+        <to>1.1.1.255</to>
+      </addressRange>
+      <address>2.2.2.2</address>
+      <address>3.3.3.3</address>
+    </ip-filter>
+  </inbound>
+  <backend>
+    <base/>
+  </backend>
+  <outbound>
+    <base/>
+  </outbound>
+  <on-error>
+    <base/>
+  </on-error>
+</policies>