Repository: HyperBrain/serverless-aws-alias (js)

Issue:
Complete stage removal not possible
When the alias plugin is used and only the master alias is left, `serverless remove` will error out, and `serverless alias remove` does not remove the master alias.

The alias plugin should support removing the master alias including the whole service stack.
Removal should only be possible if there are no other aliases deployed.

See thread https://github.com/serverless/serverless/issues/3805

Pull request description:
Removal of master alias
Fixes #56 

As soon as all deployed aliases have been removed, it is possible to remove the service by removing the master alias.

Patch:
diff --git a/README.md b/README.md
index 048f6c5..cba17ea 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
 [![Build Status](https://travis-ci.org/HyperBrain/serverless-aws-alias.svg?branch=master)](https://travis-ci.org/HyperBrain/serverless-aws-alias)
 [![Coverage Status](https://coveralls.io/repos/github/HyperBrain/serverless-aws-alias/badge.svg?branch=master)](https://coveralls.io/github/HyperBrain/serverless-aws-alias?branch=master)
 [![npm version](https://badge.fury.io/js/serverless-aws-alias.svg)](https://badge.fury.io/js/serverless-aws-alias)
+[![npm](https://img.shields.io/npm/dt/serverless-aws-alias.svg)](https://www.npmjs.com/package/serverless-aws-alias)
 
 # Serverless AWS alias plugin
 
@@ -47,6 +48,25 @@ with the alias name as option value.
 Example:
 `serverless deploy --alias myAlias`
 
+## Remove an alias
+
+See the `alias remove` command below.
+
+## Remove a service
+
+To remove a complete service, all deployed user aliases have to be removed first,
+using the `alias remove` command.
+
+To finally remove the whole service (same outcome as `serverless remove`), you have
+to remove the master (stage) alias with `serverless alias remove --alias=MY_STAGE_NAME`.
+
+This will trigger a removal of the master alias CF stack followed by a removal of
+the service stack. After the stacks have been removed, there should be no remains
+of the service.
+
+The plugin will print reasonable error messages if you miss something so that you're
+guided through the removal.
+
 ## Aliases and API Gateway
 
 In Serverless stages are, as above mentioned, parallel stacks with parallel resources.
diff --git a/index.js b/index.js
index 44a0f2e..1d0ad40 100644
--- a/index.js
+++ b/index.js
@@ -124,6 +124,13 @@ class AwsAlias {
 				.then(this.validate)
 				.then(this.listAliases),
 
+			'before:remove:remove': () => {
+				if (!this._validated) {
+					throw new this._serverless.classes.Error(`Use "serverless alias remove --alias=${this._stage}" to remove the service.`);
+				}
+				return BbPromise.resolve();
+			},
+
 			// Override the logs command - must be, because the $LATEST filter
 			// in the original logs command is not easy to change without hacks.
 			'logs:logs': () => BbPromise.bind(this)
diff --git a/lib/removeAlias.js b/lib/removeAlias.js
index c0a758d..62ebc2a 100644
--- a/lib/removeAlias.js
+++ b/lib/removeAlias.js
@@ -8,43 +8,9 @@ const NO_UPDATE_MESSAGE = 'No updates are to be performed.';
 
 module.exports = {
 
-	aliasGetAliasStackTemplate() {
+	aliasCreateStackChanges(currentTemplate, aliasStackTemplates, currentAliasStackTemplate) {
 
-		const stackName = `${this._provider.naming.getStackName()}-${this._alias}`;
-
-		// Get current aliasTemplate
-		const params = {
-			StackName: stackName,
-			TemplateStage: 'Processed'
-		};
-
-		return this._provider.request('CloudFormation',
-			'getTemplate',
-			params,
-			this._options.stage,
-			this._options.region)
-		.then(cfData => {
-			try {
-				return BbPromise.resolve(JSON.parse(cfData.TemplateBody));
-			} catch (e) {
-				return BbPromise.reject(new Error('Received malformed response from CloudFormation'));
-			}
-		})
-		.catch(err => {
-			if (_.includes(err.message, 'does not exist')) {
-				const message = `Alias ${this._alias} is not deployed.`;
-				throw new this._serverless.classes.Error(new Error(message));
-			}
-
-			throw new this._serverless.classes.Error(err);
-		});
-
-	},
-
-	aliasCreateStackChanges(currentTemplate, aliasStackTemplates) {
-
-		return this.aliasGetAliasStackTemplate()
-		.then(aliasTemplate => {
+		return BbPromise.try(() => {
 
 			const usedFuncRefs = _.uniq(
 				_.flatMap(aliasStackTemplates, template => {
@@ -72,7 +38,7 @@ module.exports = {
 			const obsoleteFuncRefs = _.reject(_.map(
 				_.assign({},
 					_.pickBy(
-						_.get(aliasTemplate, 'Resources', {}),
+						_.get(currentAliasStackTemplate, 'Resources', {}),
 						[ 'Type', 'AWS::Lambda::Alias' ])),
 				(value, key) => {
 					return _.replace(key, /Alias$/, '');
@@ -85,13 +51,39 @@ module.exports = {
 				name => `${name}LambdaFunctionArn`);
 
 			const obsoleteResources = _.reject(
-				JSON.parse(_.get(aliasTemplate, 'Outputs.AliasResources.Value', "[]")),
+				JSON.parse(_.get(currentAliasStackTemplate, 'Outputs.AliasResources.Value', "[]")),
 				resource => _.includes(usedResources, resource));
 
 			const obsoleteOutputs = _.reject(
-				JSON.parse(_.get(aliasTemplate, 'Outputs.AliasOutputs.Value', "[]")),
+				JSON.parse(_.get(currentAliasStackTemplate, 'Outputs.AliasOutputs.Value', "[]")),
 				output => _.includes(usedOutputs, output));
 
+			// Check for aliased authorizers thhat reference a removed function
+			_.forEach(obsoleteFuncRefs, obsoleteFuncRef => {
+				const authorizerName = `${obsoleteFuncRef}ApiGatewayAuthorizer${this._alias}`;
+				if (_.has(currentTemplate.Resources, authorizerName)) {
+					// find obsolete references
+					const authRefs = utils.findReferences(currentTemplate.Resources, authorizerName);
+					_.forEach(authRefs, authRef => {
+						if (_.endsWith(authRef, '.AuthorizerId')) {
+							const parent = _.get(currentTemplate.Resources, _.replace(authRef, '.AuthorizerId', ''));
+							delete parent.AuthorizerId;
+							parent.AuthorizationType = "NONE";
+						}
+					});
+					// find dependencies
+					_.forOwn(currentTemplate.Resources, resource => {
+						if (_.isArray(resource.DependsOn) && _.includes(resource.DependsOn, authorizerName)) {
+							resource.DependsOn = _.without(resource.DependsOn, authorizerName);
+						} else if (resource.DependsOn === authorizerName) {
+							delete resource.DependsOn;
+						}
+					});
+					// Add authorizer to obsolete resources
+					obsoleteResources.push(authorizerName);
+				}
+			});
+
 			// Remove all alias references that are not used in other stacks
 			_.assign(currentTemplate, {
 				Resources: _.assign({}, _.omit(currentTemplate.Resources, obsoleteFuncResources, obsoleteResources)),
@@ -101,32 +93,22 @@ module.exports = {
 			if (this.options.verbose) {
 				this._serverless.cli.log(`Remove unused resources:`);
 				_.forEach(obsoleteResources, resource => this._serverless.cli.log(`  * ${resource}`));
-				this.options.verbose && this._serverless.cli.log(`Adjust IAM policies`);
 			}
 
-			// Adjust IAM policies
-			const currentRolePolicies = _.get(currentTemplate, 'Resources.IamRoleLambdaExecution.Properties.Policies', []);
-			const currentRolePolicyStatements = _.get(currentRolePolicies[0], 'PolicyDocument.Statement', []);
+			this.options.verbose && this._serverless.cli.log(`Remove alias IAM policy`);
+			// Remove the alias IAM policy if it is not referenced in the current stage stack
+			// We cannot remove it otherwise, because the $LATEST function versions might still reference it.
+			// Then it will be deleted on the next deployment or the stage removal, whatever happend first.
+			const aliasPolicyName = `IamRoleLambdaExecution${this._alias}`;
+			if (_.isEmpty(utils.findReferences(currentTemplate.Resources, aliasPolicyName))) {
+				delete currentTemplate.Resources[`IamRoleLambdaExecution${this._alias}`];
+			} else {
+				this._serverless.cli.log(`IAM policy removal delayed - will be removed on next deployment`);
+			}
 
+			// Adjust IAM policies
 			const obsoleteRefs = _.concat(obsoleteFuncResources, obsoleteResources);
 
-			// Remove all obsolete resource references from the IAM policy statements
-			const emptyStatements = [];
-			const statementResources = utils.findReferences(currentRolePolicyStatements, obsoleteRefs);
-			_.forEach(statementResources, resourcePath => {
-				const indices = /.*?\[([0-9]+)\].*?\[([0-9]+)\]/.exec(resourcePath);
-				if (indices) {
-					const statementIndex = indices[1];
-					const resourceIndex = indices[2];
-
-					_.pullAt(currentRolePolicyStatements[statementIndex].Resource, resourceIndex);
-					if (_.isEmpty(currentRolePolicyStatements[statementIndex].Resource)) {
-						emptyStatements.push(statementIndex);
-					}
-				}
-			});
-			_.pullAt(currentRolePolicyStatements, emptyStatements);
-
 			// Set references to obsoleted resources in fct env to "REMOVED" in case
 			// the alias that is removed was the last deployment of the stage.
 			// This will change the function definition, but that does not matter
@@ -149,11 +131,11 @@ module.exports = {
 				delete currentTemplate.Outputs.ServiceEndpoint;
 			}
 
-			return BbPromise.resolve([ currentTemplate, aliasStackTemplates ]);
+			return BbPromise.resolve([ currentTemplate, aliasStackTemplates, currentAliasStackTemplate ]);
 		});
 	},
 
-	aliasApplyStackChanges(currentTemplate, aliasStackTemplates) {
+	aliasApplyStackChanges(currentTemplate, aliasStackTemplates, currentAliasStackTemplate) {
 
 		const stackName = this._provider.naming.getStackName();
 
@@ -177,6 +159,8 @@ module.exports = {
 			Tags: _.map(_.keys(stackTags), key => ({ Key: key, Value: stackTags[key] })),
 		};
 
+		this.options.verbose && this._serverless.cli.log(`Checking stack policy`);
+
     // Policy must have at least one statement, otherwise no updates would be possible at all
 		if (this.serverless.service.provider.stackPolicy &&
 				this.serverless.service.provider.stackPolicy.length) {
@@ -185,23 +169,23 @@ module.exports = {
 			});
 		}
 
-		return this.provider.request('CloudFormation',
+		return this._provider.request('CloudFormation',
       'updateStack',
       params,
       this.options.stage,
       this.options.region)
     .then(cfData => this.monitorStack('update', cfData))
-		.then(() => BbPromise.resolve([ currentTemplate, aliasStackTemplates ]))
+		.then(() => BbPromise.resolve([ currentTemplate, aliasStackTemplates, currentAliasStackTemplate ]))
     .catch(err => {
 	if (err.message === NO_UPDATE_MESSAGE) {
-		return BbPromise.resolve([ currentTemplate, aliasStackTemplates ]);
+		return BbPromise.resolve([ currentTemplate, aliasStackTemplates, currentAliasStackTemplate ]);
 	}
-	throw new this._serverless.classes.Error(err);
+	throw err;
 });
 
 	},
 
-	aliasRemoveAliasStack(currentTemplate, aliasStackTemplates) {
+	aliasRemoveAliasStack(currentTemplate, aliasStackTemplates, currentAliasStackTemplate) {
 
 		const stackName = `${this._provider.naming.getStackName()}-${this._alias}`;
 
@@ -218,33 +202,47 @@ module.exports = {
 			return this.monitorStack('removal', cfData);
 		})
 		.then(() =>{
-			return BbPromise.resolve([ currentTemplate, aliasStackTemplates ]);
+			return BbPromise.resolve([ currentTemplate, aliasStackTemplates, currentAliasStackTemplate ]);
 		})
     .catch(e => {
 	if (_.includes(e.message, 'does not exist')) {
 		const message = `Alias ${this._alias} is not deployed.`;
-		throw new this._serverless.classes.Error(new Error(message));
+		throw new this._serverless.classes.Error(message);
 	}
 
-	throw new this._serverless.classes.Error(e);
+	throw e;
 });
 
 	},
 
-	removeAlias(currentTemplate, aliasStackTemplates) {
-
-		if (this._stage && this._stage === this._alias) {
-			const message = `Cannot delete the stage alias. Did you intend to remove the service instead?`;
-			throw new this._serverless.classes.Error(new Error(message));
-		}
+	removeAlias(currentTemplate, aliasStackTemplates, currentAliasStackTemplate) {
 
 		if (this._options.noDeploy) {
+			this._serverless.cli.log('noDeploy option active - will do nothing');
 			return BbPromise.resolve();
 		}
 
+		if (this._stage && this._stage === this._alias) {
+			// Removal of the master alias is requested -> check if any other aliases are still deployed.
+			const aliases = _.map(aliasStackTemplates, aliasTemplate => _.get(aliasTemplate, 'Outputs.ServerlessAliasName.Value'));
+			if (!_.isEmpty(aliases)) {
+				throw new this._serverless.classes.Error(`Remove the other deployed aliases before removing the service: ${_.without(aliases, this._alias)}`);
+			}
+			if (_.isEmpty(currentAliasStackTemplate)) {
+				throw new this._serverless.classes.Error(`Internal error: Stack for master alias ${this._alias} is not deployed. Try to solve the problem by manual interaction with the AWS console.`);
+			}
+
+			// We're ready for removal
+			this._serverless.cli.log(`Removing master alias and stage ${this._alias} ...`);
+
+			return BbPromise.resolve([ currentTemplate, aliasStackTemplates, currentAliasStackTemplate ]).bind(this)
+			.spread(this.aliasRemoveAliasStack)
+			.then(() => this._serverless.pluginManager.spawn('remove'));
+		}
+
 		this._serverless.cli.log(`Removing alias ${this._alias} ...`);
 
-		return BbPromise.resolve([ currentTemplate, aliasStackTemplates ]).bind(this)
+		return BbPromise.resolve([ currentTemplate, aliasStackTemplates, currentAliasStackTemplate ]).bind(this)
 		.spread(this.aliasCreateStackChanges)
 		.spread(this.aliasRemoveAliasStack)
 		.spread(this.aliasApplyStackChanges)
diff --git a/lib/validate.js b/lib/validate.js
index f98d57b..de29d14 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -28,6 +28,8 @@ module.exports = {
 			this._aliasResources = true;
 		}
 
+		this._validated = true;
+
 		return BbPromise.resolve();
 
 	}


Test patch:
diff --git a/test/data/alias-stack-2.json b/test/data/alias-stack-2.json
new file mode 100644
index 0000000..42309e8
--- /dev/null
+++ b/test/data/alias-stack-2.json
@@ -0,0 +1,205 @@
+{
+  "AWSTemplateFormatVersion": "2010-09-09",
+  "Description": "Alias stack for sls-test-project-dev (myAlias)",
+  "Resources": {
+    "ServerlessAliasLogGroup": {
+      "Type": "AWS::Logs::LogGroup",
+      "Properties": {
+        "LogGroupName": "/serverless/sls-test-project-dev-myAlias",
+        "RetentionInDays": 7
+      }
+    },
+    "Testfct1Alias": {
+      "Type": "AWS::Lambda::Alias",
+      "Properties": {
+        "Description": "Echo function echoes alias",
+        "FunctionName": {
+          "Fn::ImportValue": "sls-test-project-dev-Testfct1-LambdaFunctionArn"
+        },
+        "FunctionVersion": {
+          "Fn::GetAtt": [
+            "Testfct1LambdaVersionWh5jTkiTR67V05RPWQIlzPI25WiPbdHDYNgbtAMneU",
+            "Version"
+          ]
+        },
+        "Name": "myAlias"
+      },
+      "DependsOn": [
+        "Testfct1LambdaVersionWh5jTkiTR67V05RPWQIlzPI25WiPbdHDYNgbtAMneU"
+      ]
+    },
+    "WarmUpPluginAlias": {
+      "Type": "AWS::Lambda::Alias",
+      "Properties": {
+        "Description": "Serverless WarmUP Plugin",
+        "FunctionName": {
+          "Fn::ImportValue": "sls-test-project-dev-WarmUpPlugin-LambdaFunctionArn"
+        },
+        "FunctionVersion": {
+          "Fn::GetAtt": [
+            "WarmUpPluginLambdaVersionWh5jTkiTR67V05RPWQIlzPI25WiPbdHDYNgbtAMneU",
+            "Version"
+          ]
+        },
+        "Name": "myAlias"
+      },
+      "DependsOn": [
+        "WarmUpPluginLambdaVersionWh5jTkiTR67V05RPWQIlzPI25WiPbdHDYNgbtAMneU"
+      ]
+    },
+    "Testfct1LambdaVersionWh5jTkiTR67V05RPWQIlzPI25WiPbdHDYNgbtAMneU": {
+      "Type": "AWS::Lambda::Version",
+      "DeletionPolicy": "Delete",
+      "Properties": {
+        "FunctionName": {
+          "Fn::ImportValue": "sls-test-project-dev-Testfct1-LambdaFunctionArn"
+        },
+        "CodeSha256": "Wh5jTkiTR67+V05RPWQIlzPI25WiPbdHDYNgbtAMneU=",
+        "Description": "Echo function echoes alias"
+      }
+    },
+    "WarmUpPluginLambdaVersionWh5jTkiTR67V05RPWQIlzPI25WiPbdHDYNgbtAMneU": {
+      "Type": "AWS::Lambda::Version",
+      "DeletionPolicy": "Delete",
+      "Properties": {
+        "FunctionName": {
+          "Fn::ImportValue": "sls-test-project-dev-WarmUpPlugin-LambdaFunctionArn"
+        },
+        "CodeSha256": "Wh5jTkiTR67+V05RPWQIlzPI25WiPbdHDYNgbtAMneU=",
+        "Description": "Serverless WarmUP Plugin"
+      }
+    },
+    "ApiGatewayDeployment1494367071211": {
+      "Type": "AWS::ApiGateway::Deployment",
+      "Properties": {
+        "RestApiId": {
+          "Fn::ImportValue": "sls-test-project-dev-ApiGatewayRestApi"
+        }
+      },
+      "DependsOn": []
+    },
+    "ApiGatewayStage": {
+      "Type": "AWS::ApiGateway::Stage",
+      "Properties": {
+        "StageName": "myAlias",
+        "DeploymentId": {
+          "Ref": "ApiGatewayDeployment1494367071211"
+        },
+        "RestApiId": {
+          "Fn::ImportValue": "sls-test-project-dev-ApiGatewayRestApi"
+        },
+        "Variables": {
+          "SERVERLESS_ALIAS": "myAlias",
+          "SERVERLESS_STAGE": "dev"
+        }
+      },
+      "DependsOn": [
+        "ApiGatewayDeployment1494367071211"
+      ]
+    },
+    "Testfct1LambdaPermissionApiGateway": {
+      "Type": "AWS::Lambda::Permission",
+      "Properties": {
+        "FunctionName": {
+          "Ref": "Testfct1Alias"
+        },
+        "Action": "lambda:InvokeFunction",
+        "Principal": "apigateway.amazonaws.com",
+        "SourceArn": {
+          "Fn::Join": [
+            "",
+            [
+              "arn:aws:execute-api:",
+              {
+                "Ref": "AWS::Region"
+              },
+              ":",
+              {
+                "Ref": "AWS::AccountId"
+              },
+              ":",
+              {
+                "Fn::ImportValue": "sls-test-project-dev-ApiGatewayRestApi"
+              },
+              "/*/*"
+            ]
+          ]
+        }
+      },
+      "DependsOn": [
+        "Testfct1LambdaVersionWh5jTkiTR67V05RPWQIlzPI25WiPbdHDYNgbtAMneU",
+        "Testfct1Alias"
+      ]
+    },
+    "WarmUpPluginEventsRuleSchedule1": {
+      "Type": "AWS::Events::Rule",
+      "Properties": {
+        "ScheduleExpression": "rate(5 minutes)",
+        "State": "ENABLED",
+        "Targets": [
+          {
+            "Arn": {
+              "Ref": "WarmUpPluginAlias"
+            },
+            "Id": "warmUpPluginSchedule"
+          }
+        ]
+      },
+      "DependsOn": [
+        "WarmUpPluginAlias"
+      ]
+    },
+    "WarmUpPluginLambdaPermissionEventsRuleSchedule1": {
+      "Type": "AWS::Lambda::Permission",
+      "Properties": {
+        "FunctionName": {
+          "Ref": "WarmUpPluginAlias"
+        },
+        "Action": "lambda:InvokeFunction",
+        "Principal": "events.amazonaws.com",
+        "SourceArn": {
+          "Fn::GetAtt": [
+            "WarmUpPluginEventsRuleSchedule1",
+            "Arn"
+          ]
+        }
+      },
+      "DependsOn": [
+        "WarmUpPluginAlias"
+      ]
+    }
+  },
+  "Outputs": {
+    "ServerlessAliasName": {
+      "Description": "Alias the stack represents.",
+      "Value": "myStage"
+    },
+    "ServerlessAliasLogGroup": {
+      "Description": "Log group for alias.",
+      "Value": {
+        "Ref": "ServerlessAliasLogGroup"
+      },
+      "Export": {
+        "Name": "sls-test-project-dev-myAlias-LogGroup"
+      }
+    },
+    "AliasFlags": {
+      "Description": "Alias flags.",
+      "Value": "{\"hasRole\":false}"
+    },
+    "AliasResources": {
+      "Description": "Custom resource references",
+      "Value": "[]"
+    },
+    "AliasOutputs": {
+      "Description": "Custom output references",
+      "Value": "[]"
+    },
+    "ServerlessAliasReference": {
+      "Description": "Alias stack reference.",
+      "Value": {
+        "Fn::ImportValue": "sls-test-project-dev-ServerlessAliasReference"
+      }
+    }
+  }
+}
diff --git a/test/removeAlias.test.js b/test/removeAlias.test.js
new file mode 100644
index 0000000..d9ea36a
--- /dev/null
+++ b/test/removeAlias.test.js
@@ -0,0 +1,257 @@
+'use strict';
+/**
+ * Unit tests for createAliasStack..
+ */
+
+const getInstalledPath = require('get-installed-path');
+const BbPromise = require('bluebird');
+const chai = require('chai');
+const sinon = require('sinon');
+const _ = require('lodash');
+const AWSAlias = require('../index');
+
+const serverlessPath = getInstalledPath.sync('serverless', { local: true });
+const AwsProvider = require(`${serverlessPath}/lib/plugins/aws/provider/awsProvider`);
+const Serverless = require(`${serverlessPath}/lib/Serverless`);
+
+chai.use(require('chai-as-promised'));
+chai.use(require('sinon-chai'));
+chai.use(require('chai-subset'));
+const expect = chai.expect;
+
+describe('removeAlias', () => {
+	let serverless;
+	let options;
+	let awsAlias;
+	// Sinon and stubs for SLS CF access
+	let sandbox;
+	let providerRequestStub;
+	let monitorStackStub;
+	let logStub;
+	let slsStack1;
+	let aliasStack1;
+	let aliasStack2;
+
+	before(() => {
+		sandbox = sinon.sandbox.create();
+	});
+
+	beforeEach(() => {
+		serverless = new Serverless();
+		options = {
+			alias: 'myAlias',
+			stage: 'myStage',
+			region: 'us-east-1',
+		};
+		serverless.setProvider('aws', new AwsProvider(serverless, options));
+		serverless.cli = new serverless.classes.CLI(serverless);
+		serverless.service.service = 'testService';
+		serverless.service.provider.compiledCloudFormationAliasTemplate = {};
+		awsAlias = new AWSAlias(serverless, options);
+		providerRequestStub = sandbox.stub(awsAlias._provider, 'request');
+		monitorStackStub = sandbox.stub(awsAlias, 'monitorStack');
+		logStub = sandbox.stub(serverless.cli, 'log');
+
+		slsStack1 = _.cloneDeep(require('./data/sls-stack-1.json'));
+		aliasStack1 = _.cloneDeep(require('./data/alias-stack-1.json'));
+		aliasStack2 = _.cloneDeep(require('./data/alias-stack-2.json'));
+
+		logStub.returns();
+	});
+
+	afterEach(() => {
+		sandbox.restore();
+	});
+
+	describe('#removeAlias()', () => {
+		let aliasCreateStackChangesStub;
+		let aliasRemoveAliasStackStub;
+		let aliasApplyStackChangesStub;
+		let pluginManagerSpawnStub;
+
+		beforeEach(() => {
+			aliasApplyStackChangesStub = sandbox.stub(awsAlias, 'aliasApplyStackChanges');
+			aliasCreateStackChangesStub = sandbox.stub(awsAlias, 'aliasCreateStackChanges');
+			aliasRemoveAliasStackStub = sandbox.stub(awsAlias, 'aliasRemoveAliasStack');
+			pluginManagerSpawnStub = sandbox.stub(awsAlias._serverless.pluginManager, 'spawn');
+		});
+
+		it('should do nothing with noDeploy', () => {
+			awsAlias._options = { noDeploy: true };
+
+			return expect(awsAlias.removeAlias()).to.be.fulfilled
+			.then(() => BbPromise.all([
+				expect(aliasApplyStackChangesStub).to.not.have.been.called,
+				expect(aliasCreateStackChangesStub).to.not.have.been.called,
+				expect(aliasRemoveAliasStackStub).to.not.have.been.called,
+				expect(pluginManagerSpawnStub).to.not.have.been.called,
+			]));
+		});
+
+		it('should error if an alias is deployed on stage removal', () => {
+			awsAlias._options = { alias: 'myStage' };
+			awsAlias._alias = 'myStage';
+
+			expect(() => awsAlias.removeAlias(slsStack1, [ aliasStack1 ], aliasStack2)).to.throw(/myAlias/);
+			return BbPromise.all([
+				expect(aliasApplyStackChangesStub).to.not.have.been.called,
+				expect(aliasCreateStackChangesStub).to.not.have.been.called,
+				expect(aliasRemoveAliasStackStub).to.not.have.been.called,
+				expect(pluginManagerSpawnStub).to.not.have.been.called,
+			]);
+		});
+
+		it('should error if the master alias is not deployed on stage removal', () => {
+			awsAlias._options = { alias: 'myStage' };
+			awsAlias._alias = 'myStage';
+
+			expect(() => awsAlias.removeAlias(slsStack1, [], {})).to.throw(/Internal error/);
+			return BbPromise.all([
+				expect(aliasApplyStackChangesStub).to.not.have.been.called,
+				expect(aliasCreateStackChangesStub).to.not.have.been.called,
+				expect(aliasRemoveAliasStackStub).to.not.have.been.called,
+				expect(pluginManagerSpawnStub).to.not.have.been.called,
+			]);
+		});
+
+		it('should remove alias and service stack on stage removal', () => {
+			awsAlias._options = { alias: 'myStage' };
+			awsAlias._alias = 'myStage';
+
+			return expect(awsAlias.removeAlias(slsStack1, [], aliasStack2)).to.be.fulfilled
+			.then(() => BbPromise.all([
+				expect(aliasApplyStackChangesStub).to.not.have.been.called,
+				expect(aliasCreateStackChangesStub).to.not.have.been.called,
+				expect(aliasRemoveAliasStackStub).to.have.been.calledOnce,
+				expect(pluginManagerSpawnStub).to.have.been.calledWithExactly('remove'),
+			]));
+		});
+
+		it('should remove alias stack', () => {
+			aliasApplyStackChangesStub.returns([ slsStack1, [ aliasStack2 ], aliasStack1 ]);
+			aliasCreateStackChangesStub.returns([ slsStack1, [ aliasStack2 ], aliasStack1 ]);
+			aliasRemoveAliasStackStub.returns([ slsStack1, [ aliasStack2 ], aliasStack1 ]);
+
+			return expect(awsAlias.removeAlias(slsStack1, [ aliasStack2 ], aliasStack1)).to.be.fulfilled
+			.then(() => BbPromise.all([
+				expect(aliasCreateStackChangesStub).to.have.been.calledOnce,
+				expect(aliasRemoveAliasStackStub).to.have.been.calledOnce,
+				expect(aliasApplyStackChangesStub).to.have.been.calledOnce,
+				expect(pluginManagerSpawnStub).to.not.have.been.called,
+			]));
+		});
+	});
+
+	describe('#aliasRemoveAliasStack()', () => {
+		it('should call CF to remove stack', () => {
+			const requestResult = {
+				status: 'ok'
+			};
+			providerRequestStub.returns(BbPromise.resolve(requestResult));
+			monitorStackStub.returns(BbPromise.resolve());
+
+			return expect(awsAlias.aliasRemoveAliasStack(slsStack1, [ aliasStack2 ], aliasStack1)).to.be.fulfilled
+			.then(() => BbPromise.all([
+				expect(providerRequestStub).to.have.been.calledOnce,
+
+			]));
+		});
+
+		it('should throw an error if the stack does not exist', () => {
+			providerRequestStub.returns(BbPromise.reject(new Error('stack does not exist')));
+			monitorStackStub.returns(BbPromise.resolve());
+
+			return expect(awsAlias.aliasRemoveAliasStack(slsStack1, [ aliasStack2 ], aliasStack1)).to.be.rejectedWith('is not deployed')
+			.then(() => BbPromise.all([
+				expect(providerRequestStub).to.have.been.calledOnce,
+			]));
+		});
+
+		it('should propagate CF errors', () => {
+			providerRequestStub.returns(BbPromise.reject(new Error('CF Error')));
+			monitorStackStub.returns(BbPromise.resolve());
+
+			return expect(awsAlias.aliasRemoveAliasStack(slsStack1, [ aliasStack2 ], aliasStack1)).to.be.rejectedWith('CF Error')
+			.then(() => BbPromise.all([
+				expect(providerRequestStub).to.have.been.calledOnce,
+			]));
+		});
+	});
+
+	describe('#aliasApplyStackChanges()', () => {
+		it('should call CF and update stage stack', () => {
+			const requestResult = {
+				status: 'ok'
+			};
+			providerRequestStub.returns(BbPromise.resolve(requestResult));
+			monitorStackStub.returns(BbPromise.resolve());
+
+			return expect(awsAlias.aliasApplyStackChanges(slsStack1, [ aliasStack2 ], aliasStack1)).to.be.fulfilled
+			.then(() => BbPromise.all([
+				expect(providerRequestStub).to.have.been.calledOnce,
+				expect(providerRequestStub.getCall(0).args[0]).to.equal('CloudFormation'),
+				expect(providerRequestStub.getCall(0).args[1]).to.equal('updateStack'),
+				expect(providerRequestStub.getCall(0).args[2]).to.containSubset({ StackName: 'testService-myStage' }),
+			]));
+		});
+
+		it('should resolve if no updates are applied', () => {
+			providerRequestStub.returns(BbPromise.reject(new Error('No updates are to be performed.')));
+			monitorStackStub.returns(BbPromise.resolve());
+
+			return expect(awsAlias.aliasApplyStackChanges(slsStack1, [ aliasStack2 ], aliasStack1)).to.be.fulfilled
+			.then(() => BbPromise.all([
+				expect(providerRequestStub).to.have.been.calledOnce,
+			]));
+		});
+
+		it('should propagate CF errors', () => {
+			providerRequestStub.returns(BbPromise.reject(new Error('CF Error')));
+			monitorStackStub.returns(BbPromise.resolve());
+
+			return expect(awsAlias.aliasApplyStackChanges(slsStack1, [ aliasStack2 ], aliasStack1)).to.be.rejectedWith('CF Error')
+			.then(() => BbPromise.all([
+				expect(providerRequestStub).to.have.been.calledOnce,
+			]));
+		});
+	});
+
+	it('should merge custom tags', () => {
+		const requestResult = {
+			status: 'ok'
+		};
+		providerRequestStub.returns(BbPromise.resolve(requestResult));
+		monitorStackStub.returns(BbPromise.resolve());
+		awsAlias._serverless.service.provider.stackTags = { tag1: '1', tag2: '2' };
+
+		return expect(awsAlias.aliasApplyStackChanges(slsStack1, [ aliasStack2 ], aliasStack1)).to.be.fulfilled
+		.then(() => BbPromise.all([
+			expect(providerRequestStub).to.have.been.calledOnce,
+			expect(providerRequestStub.getCall(0).args[2]).to.containSubset({ Tags: [
+				{
+					Key: 'tag1',
+					Value: '1'
+				},
+				{
+					Key: 'tag2',
+					Value: '2'
+				}
+			] }),
+		]));
+	});
+
+	it('should use custom stack policy', () => {
+		const requestResult = {
+			status: 'ok'
+		};
+		providerRequestStub.returns(BbPromise.resolve(requestResult));
+		monitorStackStub.returns(BbPromise.resolve());
+		awsAlias._serverless.service.provider.stackPolicy = [{ title: 'myPolicy' }];
+
+		return expect(awsAlias.aliasApplyStackChanges(slsStack1, [ aliasStack2 ], aliasStack1)).to.be.fulfilled
+		.then(() => BbPromise.all([
+			expect(providerRequestStub).to.have.been.calledOnce,
+			expect(providerRequestStub.getCall(0).args[2]).to.containSubset({ StackPolicyBody: '{"Statement":[{"title":"myPolicy"}]}' }),
+		]));
+	});
+});