diff --git a/cf-custom-resources/lib/nlb-cert-validator.js b/cf-custom-resources/lib/nlb-cert-validator.js new file mode 100644 index 00000000000..99b6b9207a3 --- /dev/null +++ b/cf-custom-resources/lib/nlb-cert-validator.js @@ -0,0 +1,763 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const AWS = require('aws-sdk'); +const CRYPTO = require("crypto"); +const ATTEMPTS_VALIDATION_OPTIONS_READY = 10; +const ATTEMPTS_RECORD_SETS_CHANGE = 10; +const DELAY_RECORD_SETS_CHANGE_IN_S = 30; +const ATTEMPTS_CERTIFICATE_VALIDATED = 19; +const ATTEMPTS_CERTIFICATE_NOT_IN_USE = 12; +const DELAY_CERTIFICATE_VALIDATED_IN_S = 30; + +let envHostedZoneID, appName, envName, serviceName, certificateDomain, domainTypes, rootDNSRole, domainName; +let defaultSleep = function (ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; +let sleep = defaultSleep; +let random = Math.random; + +const appRoute53Context = () => { + let client; + return () => { + if (!client) { + client = new AWS.Route53({ + credentials: new AWS.ChainableTemporaryCredentials({ + params: { RoleArn: rootDNSRole, }, + masterCredentials: new AWS.EnvironmentCredentials("AWS"), + }), + }); + } + return client; + }; +} + +const envRoute53Context = () => { + let client; + return () => { + if (!client) { + client = new AWS.Route53(); + } + return client; + }; +} + +const acmContext = () => { + let client; + return () => { + if (!client) { + client = new AWS.ACM(); + } + return client; + }; +} + +const resourceGroupsTaggingAPIContext = () => { + let client; + return () => { + if (!client) { + client = new AWS.ResourceGroupsTaggingAPI(); + } + return client; + }; +} + +const clients = { + app: { + route53: appRoute53Context(), + }, + root: { + route53: appRoute53Context(), + }, + env: { + route53:envRoute53Context(), + }, + acm: acmContext(), + resourceGroupsTaggingAPI: resourceGroupsTaggingAPIContext(), +} + +const appHostedZoneIDContext = () => { + let id; + return async () => { + if (!id) { + id = await hostedZoneIDByName(`${appName}.${domainName}`); + } + return id + }; +} + +const rootHostedZoneIDContext = () => { + let id; + return async () => { + if (!id) { + id = await hostedZoneIDByName(`${domainName}`); + } + return id + }; +} + +let hostedZoneID = { + app: appHostedZoneIDContext(), + root: rootHostedZoneIDContext(), +} + +/** + * Upload a CloudFormation response object to S3. + * + * @param {object} event the Lambda event payload received by the handler function + * @param {object} context the Lambda context received by the handler function + * @param {string} responseStatus the response status, either 'SUCCESS' or 'FAILED' + * @param {string} physicalResourceId CloudFormation physical resource ID + * @param {object} [responseData] arbitrary response data object + * @param {string} [reason] reason for failure, if any, to convey to the user + * @returns {Promise} Promise that is resolved on success, or rejected on connection error or HTTP error response + */ +function report ( + event, + context, + responseStatus, + physicalResourceId, + responseData, + reason +) { + return new Promise((resolve, reject) => { + const https = require("https"); + const { URL } = require("url"); + + let reasonWithLogInfo = `${reason} (Log: ${context.logGroupName}/${context.logStreamName})`; + let responseBody = JSON.stringify({ + Status: responseStatus, + Reason: reasonWithLogInfo, + PhysicalResourceId: physicalResourceId || context.logStreamName, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + Data: responseData, + }); + + const parsedUrl = new URL(event.ResponseURL); + const options = { + hostname: parsedUrl.hostname, + port: 443, + path: parsedUrl.pathname + parsedUrl.search, + method: "PUT", + headers: { + "Content-Type": "", + "Content-Length": responseBody.length, + }, + }; + + https + .request(options) + .on("error", reject) + .on("response", (res) => { + res.resume(); + if (res.statusCode >= 400) { + reject(new Error(`Error ${res.statusCode}: ${res.statusMessage}`)); + } else { + resolve(); + } + }) + .end(responseBody, "utf8"); + }); +} + +exports.handler = async function (event, context) { + // Destruct resource properties into local variables. + const props = event.ResourceProperties; + let {LoadBalancerDNS: loadBalancerDNS, + } = props; + const aliases = new Set(props.Aliases); + + // Initialize global variables. + envHostedZoneID = props.EnvHostedZoneId; + envName = props.EnvName; + appName = props.AppName; + serviceName = props.ServiceName; + domainName = props.DomainName; + rootDNSRole = props.RootDNSRole; + certificateDomain = `${serviceName}-nlb.${envName}.${appName}.${domainName}`; + domainTypes = { + EnvDomainZone: { + regex: new RegExp(`^([^\.]+\.)?${envName}.${appName}.${domainName}`), + domain: `${envName}.${appName}.${domainName}`, + }, + AppDomainZone: { + regex: new RegExp(`^([^\.]+\.)?${appName}.${domainName}`), + domain: `${appName}.${domainName}`, + }, + RootDomainZone: { + regex: new RegExp(`^([^\.]+\.)?${domainName}`), + domain: `${domainName}`, + }, + }; + + let aliasesSorted = [...aliases].sort().join(","); + let physicalResourceID = event.PhysicalResourceId; // The certificate ARN. By default, keep old physical resource ID unchanged. + let handler = async function() { + switch (event.RequestType) { + case "Update": + let oldAliases = new Set(event.OldResourceProperties.Aliases); + let oldAliasesSorted = [...oldAliases].sort().join(","); + if (oldAliasesSorted === aliasesSorted) { + break; + } + // Fallthrough to "Create". When the aliases are different, the same actions are taken for both "Update" and "Create". + case "Create": + await validateAliases(aliases, loadBalancerDNS); + const certificateARN = await requestCertificate({ + aliases: aliases, + idempotencyToken: CRYPTO + .createHash("md5") + .update(`/${serviceName}/${aliasesSorted}`) + .digest("hex")}); + physicalResourceID = certificateARN; // Update the physical resource ID if a new certificate is created. + const options = await waitForValidationOptionsToBeReady(certificateARN, aliases); + await validate(certificateARN, options); + break; + case "Delete": + if (!physicalResourceID || !physicalResourceID.startsWith("arn:")) { + // This means no certificate has been created, nor any records. Exit without doing anything. + break; + } + let unusedOptions = await unusedValidationOptions(physicalResourceID, loadBalancerDNS); + await devalidate(unusedOptions); + await deleteCertificate(physicalResourceID); + break; + default: + throw new Error(`Unsupported request type ${event.RequestType}`); + } + }; + + try { + await Promise.race([exports.deadlineExpired(), handler(),]); + await report(event, context, "SUCCESS", physicalResourceID); + } catch (err) { + console.log(`Caught error for service ${serviceName}: ${err.message}`); + await report(event, context, "FAILED", physicalResourceID, null, err.message); + } +}; + +/** + * Delete the certificate. + * + * @param certARN The ARN of the certificate to delete. + * @returns {Promise} + */ +async function deleteCertificate(certARN) { + // NOTE: wait for certificate to be not in-used. + let attempt; + for (attempt = 0; attempt < ATTEMPTS_CERTIFICATE_NOT_IN_USE; attempt++) { + let certificate; + try { + ({ Certificate:certificate } = await clients.acm().describeCertificate({ + CertificateArn: certARN, + }).promise()); + } catch (err) { + if (err.name === "ResourceNotFoundException") { + return; + } + throw err; + } + + if (!certificate.InUseBy || certificate.InUseBy.length <= 0) { + break; + } + await sleep(30000); + } + + if (attempt >= ATTEMPTS_CERTIFICATE_NOT_IN_USE) { + throw new Error( + `Certificate still in use after checking for ${ATTEMPTS_CERTIFICATE_NOT_IN_USE} attempts.` + ); + } + + await clients.acm().deleteCertificate({ CertificateArn: certARN }).promise().catch(err => { + if (err.name !== "ResourceNotFoundException") { + throw err; + } + }); +} + +/** + * Validate that the aliases are not in use. + * + * @param {Set} aliases for the service. + * @param {String} loadBalancerDNS the DNS of the service's load balancer. + * @throws error if at least one of the aliases is not valid. + */ +async function validateAliases(aliases, loadBalancerDNS) { + let promises = []; + + for (let alias of aliases) { + let {hostedZoneID, route53Client } = await domainResources(alias); + const promise = route53Client.listResourceRecordSets({ + HostedZoneId: hostedZoneID, + MaxItems: "1", + StartRecordName: alias, + }).promise().then(({ ResourceRecordSets: recordSet }) => { + if (!targetRecordExists(alias, recordSet)) { + return; + } + let aliasTarget = recordSet[0].AliasTarget; + if (aliasTarget && aliasTarget.DNSName.toLowerCase() === `${loadBalancerDNS.toLowerCase()}.`) { + return; // The record is an alias record and is in use by myself, hence valid. + } + if (aliasTarget) { + throw new Error(`Alias ${alias} is already in use by ${aliasTarget.DNSName}. This could be another load balancer of a different service.`); + } + throw new Error(`Alias ${alias} is already in use`); + }) + promises.push(promise); + } + await Promise.all(promises); +} + +/** + * Requests a public certificate from AWS Certificate Manager, using DNS validation. + * + * @param {Object} requestCertificateInput is the input to requestCertificate, containing the alias and idempotencyToken. + * @return {String} The ARN of the requested certificate. + */ +async function requestCertificate({ aliases, idempotencyToken }) { + const { CertificateArn } = await clients.acm().requestCertificate({ + DomainName: certificateDomain, + IdempotencyToken: idempotencyToken, + SubjectAlternativeNames: aliases.size === 0? null: [...aliases], + Tags: [ + { + Key: "copilot-application", + Value: appName, + }, + { + Key: "copilot-environment", + Value: envName, + }, + { + Key: "copilot-service", + Value: serviceName, + } + ], + ValidationMethod: "DNS" + }).promise(); + return CertificateArn; +} + +/** + * Wait until the validation options are ready + * + * @param certificateARN + * @param {Set} aliases for the service. + */ +async function waitForValidationOptionsToBeReady(certificateARN, aliases) { + let expectedCount = aliases.size + 1; // Expect one validation option for each alias and the cert domain. + + let attempt; // TODO: This wait loops could be further abstracted. + for (attempt = 0; attempt < ATTEMPTS_VALIDATION_OPTIONS_READY; attempt++) { + let readyCount = 0; + const { Certificate } = await clients.acm().describeCertificate({ + CertificateArn: certificateARN, + }).promise(); + const options = Certificate.DomainValidationOptions || []; + options.forEach(option => { + if (option.ResourceRecord && (aliases.has(option.DomainName) || option.DomainName.toLowerCase() === certificateDomain.toLowerCase())) { + readyCount++; + } + }) + if (readyCount === expectedCount) { + return options; + } + + // Exponential backoff with jitter based on 200ms base + // component of backoff fixed to ensure minimum total wait time on + // slow targets. + const base = Math.pow(2, attempt); + await sleep(random() * base * 50 + base * 150); + } + throw new Error(`resource validation records are not ready after ${attempt} tries`); +} + +/** + * Validate the certificate. + * + * @param {String} certificateARN + * @param {Array} validationOptions + */ +async function validate(certificateARN, validationOptions) { + let promises = []; + for (let option of validationOptions) { + promises.push(validateOption(option)); + } + await Promise.all(promises); + await clients.acm().waitFor("certificateValidated", { + // Wait up to 9 minutes and 30 seconds + $waiter: { + delay: DELAY_CERTIFICATE_VALIDATED_IN_S, + maxAttempts: ATTEMPTS_CERTIFICATE_VALIDATED, + }, + CertificateArn: certificateARN, + }).promise(); +} + +/** + * Upsert the validation record for the alias. + * + * @param {Object} option + */ +async function validateOption(option) { + let changes = [{ + Action: "UPSERT", + ResourceRecordSet: { + Name: option.ResourceRecord.Name, + Type: option.ResourceRecord.Type, + TTL: 60, + ResourceRecords: [ + { + Value: option.ResourceRecord.Value, + }, + ], + } + }]; + + let {hostedZoneID, route53Client} = await domainResources(option.DomainName); + let { ChangeInfo } = await route53Client.changeResourceRecordSets({ + ChangeBatch: { + Comment: `Validate the certificate for the alias ${option.DomainName}`, + Changes: changes, + }, + HostedZoneId: hostedZoneID, + }).promise(); + + await route53Client.waitFor('resourceRecordSetsChanged', { + // Wait up to 5 minutes + $waiter: { + delay: DELAY_RECORD_SETS_CHANGE_IN_S, + maxAttempts: ATTEMPTS_RECORD_SETS_CHANGE, + }, + Id: ChangeInfo.Id, + }).promise(); +} + +/** + * Retrieve validation options that will be unused by any service. + * + * @param {String} ownedCertARN The ARN of the certificate that this custom resource manages. + * @param {String} loadBalancerDNS The DNS of the load balancer used by this service. + * @returns {Promise>} + */ +async function unusedValidationOptions(ownedCertARN, loadBalancerDNS) { + // Look for validation options that will be no longer needed by this service. + const certificates = await serviceCertificates(); + const { certOwned: certPendingDeletion, otherCerts: certInUse } = categorizeCertificates(certificates, ownedCertARN); + if (!certPendingDeletion) { + // Cannot find the certificate that is pending deletion; perhaps it is deleted already. Exit peacefully. + return new Set(); + } + let optionsPendingDeletion = await unusedOptionsByService(certPendingDeletion, certInUse); + + // For each of the options pending deletion, validate if it is in use by other services. If it is, Copilot + // will not delete it. + let promises = []; + for (const option of optionsPendingDeletion) { + const domainName = option["DomainName"]; + // NOTE: The client is initialized outside of the `inUseByOtherServices` function because AWS-SDK mocks cannot + // mock its API calls if it is initialized in a callback. + let route53Client; + try { + ({route53Client} = await domainResources(domainName)); + } catch (err) { + // NOTE: The UnrecognizedDomainTypeError is swallowed here because it is preferably handled inside + // `inUseByOtherServices`. + if (!err instanceof UnrecognizedDomainTypeError) { + throw err; + } + } + const promise = inUseByOtherServices(loadBalancerDNS, domainName, route53Client).then((isUsed) => { + if (isUsed) { + optionsPendingDeletion.delete(option); + } + }); + promises.push(promise); + } + await Promise.all(promises); + return optionsPendingDeletion; +} + + +/** + * De-validate the certificate by removing its validation options. + * @param {Object} unusedOptions + * @returns {Promise} + */ +async function devalidate(unusedOptions) { + let promises = []; + for (let option of unusedOptions) { + promises.push(devalidateOption(option)); + } + await Promise.all(promises); +} + +/** + * Delete the validation option from its corresponding hosted zone. + * @param {Object} option + * @returns {Promise} + */ +async function devalidateOption(option) { + let changes = [{ + Action: "DELETE", + ResourceRecordSet: { + Name: option.ResourceRecord.Name, + Type: option.ResourceRecord.Type, + TTL: 60, + ResourceRecords: [ + { + Value: option.ResourceRecord.Value, + } + ], + } + }]; + + let {hostedZoneID, route53Client} = await domainResources(option.DomainName); + let changeResourceRecordSetsInput = { + ChangeBatch: { + Comment: `Delete the validation record for ${option.DomainName}`, + Changes: changes, + }, + HostedZoneId: hostedZoneID, + } + let changeInfo; + try { + ({ ChangeInfo: changeInfo } = await route53Client.changeResourceRecordSets(changeResourceRecordSetsInput).promise()); + } catch (e) { + let recordSetNotFoundErrMessageRegex = new RegExp(".*Tried to delete resource record set.*but it was not found.*"); + if (recordSetNotFoundErrMessageRegex.test(e.message)) { + return; // If we attempt to `DELETE` a record that doesn't exist, the job is already done, skip waiting. + } + throw new Error(`delete record ${option.ResourceRecord.Name}: ` + e.message); + } + + await route53Client.waitFor('resourceRecordSetsChanged', { + // Wait up to 5 minutes + $waiter: { + delay: DELAY_RECORD_SETS_CHANGE_IN_S, + maxAttempts: ATTEMPTS_RECORD_SETS_CHANGE, + }, + Id: changeInfo.Id, + }).promise(); +} + +/** + * Retrieve all certificates used for the service and cache the results. + * @returns {Array} An array of descriptions for the certificates used by the service. + */ +async function serviceCertificates() { + let { ResourceTagMappingList } = await clients.resourceGroupsTaggingAPI().getResources({ + TagFilters: [ + { + Key: "copilot-application", + Values: [appName], + }, + { + Key: "copilot-environment", + Values: [envName], + }, + { + Key: "copilot-service", + Values: [serviceName], + } + ], + ResourceTypeFilters: ["acm:certificate"] + }).promise(); + + let certificates = []; + let promises = []; + for (const {ResourceARN: arn} of ResourceTagMappingList) { + let promise = clients.acm().describeCertificate({ + CertificateArn: arn + }).promise().then(( { Certificate } ) => { + certificates.push(Certificate); + }); + promises.push(promise); + } + await Promise.all(promises); + return certificates; +} + +/** + * Retrieve the validation options that are pending deletion. An option is pending deletion if it is only used to + * validate a certificate that is pending deletion. + * @param {Object} certPendingDeletion The certificate that is pending deletion. + * @param {Array} certsInUse + * @returns {Promise>} options that are pending deletion. + */ +async function unusedOptionsByService(certPendingDeletion, certsInUse) { + let optionsPendingDeletion = new Map(); + for (const option of certPendingDeletion["DomainValidationOptions"]) { + if (option["ResourceRecord"]) { + optionsPendingDeletion.set(JSON.stringify(option["ResourceRecord"]), option); + } + } + for (const { DomainValidationOptions: validationOptions } of certsInUse) { + for (const option of validationOptions) { + if (option["ResourceRecord"]) { + optionsPendingDeletion.delete(JSON.stringify(option["ResourceRecord"])); + } + } + } + let options = new Set(); + for (const opt of optionsPendingDeletion.values()) { + options.add(opt); + } + return options; +} + +/** + * Validate if the domain name is currently in use by other services. + * @param loadBalancerDNS The DNS of the Network Load Balancer used by this service. The domain name in considered in use + * by this service, not other services, if it is an alias target pointing to this service's load balancer DNS. + * @param domainName + * @param route53Client The Route53 client to use for the domain name. This client can be a app-level client, or an + * env-level client, depending on the pattern of the domain name. It is initialized outside of the function because + * AWS-SDK mocks cannot mock the API call if the client is initialized in a callback. + * @returns {Promise} True if it is considered in use; otherwise false. + */ +async function inUseByOtherServices(loadBalancerDNS, domainName, route53Client) { + let hostedZoneID; + try { + ({hostedZoneID} = await domainResources(domainName)); + } catch (err) { + if (err instanceof UnrecognizedDomainTypeError) { + console.log(`Found ${domainName} in subject alternative names. ` + + "It does not match any of these patterns: '...', '..' or '.'. " + + "This is unexpected. We don't error out as it may not cause any issue."); + return true; // This option has unrecognized pattern, we can't check if it is in use, so we assume it is in use. + } + throw err; + } + const { ResourceRecordSets: recordSet } = await route53Client.listResourceRecordSets({ + HostedZoneId: hostedZoneID, + MaxItems: "1", + StartRecordName: domainName, + }).promise(); + if (!targetRecordExists(domainName, recordSet)) { + return false; // If there is no record using this domain, it is not in use. + } + const inUseByMySelf = recordSet[0].AliasTarget && recordSet[0].AliasTarget.DNSName.toLowerCase() === `${loadBalancerDNS.toLowerCase()}.` + return !inUseByMySelf +} + +/** + * Categorize a list of certificates into the certificate that corresponds to this particular custom resource, and other certificates. + * @param {Array} certificates + * @param {String} ownedCertARN The ARN of the certificate that this custom resource manages. + * @returns {Object},Array{Object} + */ +function categorizeCertificates(certificates, ownedCertARN) { + let certOwned; + let otherCerts = []; + for (const cert of certificates) { + if (cert["CertificateArn"].toLowerCase() === ownedCertARN.toLowerCase()) { + certOwned = cert; + } else { + otherCerts.push(cert); + } + } + return { certOwned, otherCerts }; +} + +/** + * Validate if the exact record exits in the set of records. + * @param targetDomainName The domain name that the target record should have + * @param recordSet + * @returns {boolean} + */ +function targetRecordExists(targetDomainName, recordSet) { + if (!recordSet || recordSet.length === 0) { + return false; + } + return recordSet[0].Name === `${targetDomainName}.`; +} + +async function hostedZoneIDByName(domain) { + const { HostedZones } = await clients.app.route53() + .listHostedZonesByName({ + DNSName: domain, + MaxItems: "1", + }).promise(); + if (!HostedZones || HostedZones.length === 0) { + throw new Error( `Couldn't find any Hosted Zone with DNS name ${domainName}.`); + } + return HostedZones[0].Id.split("/").pop(); +} + +async function domainResources (alias) { + if (domainTypes.EnvDomainZone.regex.test(alias)) { + return { + domain: domainTypes.EnvDomainZone.domain, + route53Client: clients.env.route53(), + hostedZoneID: envHostedZoneID, + }; + } + if (domainTypes.AppDomainZone.regex.test(alias)) { + return { + domain: domainTypes.AppDomainZone.domain, + route53Client: clients.app.route53(), + hostedZoneID: await hostedZoneID.app(), + }; + } + if (domainTypes.RootDomainZone.regex.test(alias)) { + return { + domain: domainTypes.RootDomainZone.domain, + route53Client: clients.root.route53(), + hostedZoneID: await hostedZoneID.root(), + }; + } + throw new UnrecognizedDomainTypeError(`unrecognized domain type for ${alias}`); +} + +function setEqual(setA, setB) { + if (setA.size !== setB.size) { + return false; + } + + for (let elem of setA) { + if (!setB.has(elem)) { + return false; + } + } + return true; +} + +function UnrecognizedDomainTypeError(message = "") { + this.message = message; +} +UnrecognizedDomainTypeError.prototype = Object.create(Error.prototype, { + constructor: { + value: Error, + enumerable: false, + writable: true, + configurable: true + } +}); + + +exports.deadlineExpired = function () { + return new Promise(function (resolve, reject) { + setTimeout( + reject, + 14 * 60 * 1000 + 30 * 1000 /* 14.5 minutes*/, + new Error(`Lambda took longer than 14.5 minutes to update custom domain`) + ); + }); +}; + +exports.withSleep = function (s) { + sleep = s; +}; +exports.reset = function () { + sleep = defaultSleep; +}; +exports.withDeadlineExpired = function (d) { + exports.deadlineExpired = d; +}; +exports.attemptsValidationOptionsReady = ATTEMPTS_VALIDATION_OPTIONS_READY; diff --git a/cf-custom-resources/lib/nlb-custom-domain.js b/cf-custom-resources/lib/nlb-custom-domain.js new file mode 100644 index 00000000000..9700812cf56 --- /dev/null +++ b/cf-custom-resources/lib/nlb-custom-domain.js @@ -0,0 +1,489 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const AWS = require('aws-sdk'); +const ATTEMPTS_VALIDATION_OPTIONS_READY = 10; +const ATTEMPTS_RECORD_SETS_CHANGE = 10; +const DELAY_RECORD_SETS_CHANGE_IN_S = 30; + +let envHostedZoneID, appName, envName, serviceName, certificateDomain, domainTypes, rootDNSRole, domainName; +let defaultSleep = function (ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; +let sleep = defaultSleep; + +const appRoute53Context = () => { + let client; + return () => { + if (!client) { + client = new AWS.Route53({ + credentials: new AWS.ChainableTemporaryCredentials({ + params: { RoleArn: rootDNSRole, }, + masterCredentials: new AWS.EnvironmentCredentials("AWS"), + }), + }); + } + return client; + }; +} + +const envRoute53Context = () => { + let client; + return () => { + if (!client) { + client = new AWS.Route53(); + } + return client; + }; +} + +const acmContext = () => { + let client; + return () => { + if (!client) { + client = new AWS.ACM(); + } + return client; + }; +} + +const resourceGroupsTaggingAPIContext = () => { + let client; + return () => { + if (!client) { + client = new AWS.ResourceGroupsTaggingAPI(); + } + return client; + }; +} + +const clients = { + app: { + route53: appRoute53Context(), + }, + root: { + route53: appRoute53Context(), + }, + env: { + route53:envRoute53Context(), + }, + acm: acmContext(), + resourceGroupsTaggingAPI: resourceGroupsTaggingAPIContext(), +} + +const appHostedZoneIDContext = () => { + let id; + return async () => { + if (!id) { + id = await hostedZoneIDByName(`${appName}.${domainName}`); + } + return id + }; +} + +const rootHostedZoneIDContext = () => { + let id; + return async () => { + if (!id) { + id = await hostedZoneIDByName(`${domainName}`); + } + return id + }; +} + +let hostedZoneID = { + app: appHostedZoneIDContext(), + root: rootHostedZoneIDContext(), +} + +/** + * Upload a CloudFormation response object to S3. + * + * @param {object} event the Lambda event payload received by the handler function + * @param {object} context the Lambda context received by the handler function + * @param {string} responseStatus the response status, either 'SUCCESS' or 'FAILED' + * @param {string} physicalResourceId CloudFormation physical resource ID + * @param {object} [responseData] arbitrary response data object + * @param {string} [reason] reason for failure, if any, to convey to the user + * @returns {Promise} Promise that is resolved on success, or rejected on connection error or HTTP error response + */ +function report ( + event, + context, + responseStatus, + physicalResourceId, + responseData, + reason +) { + return new Promise((resolve, reject) => { + const https = require("https"); + const { URL } = require("url"); + + let reasonWithLogInfo = `${reason} (Log: ${context.logGroupName}/${context.logStreamName})`; + let responseBody = JSON.stringify({ + Status: responseStatus, + Reason: reasonWithLogInfo, + PhysicalResourceId: physicalResourceId || context.logStreamName, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + Data: responseData, + }); + + const parsedUrl = new URL(event.ResponseURL); + const options = { + hostname: parsedUrl.hostname, + port: 443, + path: parsedUrl.pathname + parsedUrl.search, + method: "PUT", + headers: { + "Content-Type": "", + "Content-Length": responseBody.length, + }, + }; + + https + .request(options) + .on("error", reject) + .on("response", (res) => { + res.resume(); + if (res.statusCode >= 400) { + reject(new Error(`Error ${res.statusCode}: ${res.statusMessage}`)); + } else { + resolve(); + } + }) + .end(responseBody, "utf8"); + }); +} + +exports.handler = async function (event, context) { + // Destruct resource properties into local variables. + const props = event.ResourceProperties; + let {LoadBalancerDNS: loadBalancerDNS, + LoadBalancerHostedZoneID: loadBalancerHostedZoneID, + } = props; + const aliases = new Set(props.Aliases); + + // Initialize global variables. + envHostedZoneID = props.EnvHostedZoneId; + envName = props.EnvName; + appName = props.AppName; + serviceName = props.ServiceName; + domainName = props.DomainName; + rootDNSRole = props.RootDNSRole; + certificateDomain = `${serviceName}-nlb.${envName}.${appName}.${domainName}`; + domainTypes = { + EnvDomainZone: { + regex: new RegExp(`^([^\.]+\.)?${envName}.${appName}.${domainName}`), + domain: `${envName}.${appName}.${domainName}`, + }, + AppDomainZone: { + regex: new RegExp(`^([^\.]+\.)?${appName}.${domainName}`), + domain: `${appName}.${domainName}`, + }, + RootDomainZone: { + regex: new RegExp(`^([^\.]+\.)?${domainName}`), + domain: `${domainName}`, + }, + }; + + const physicalResourceID = event.LogicalResourceId; // The PhysicalResourceID never changes because LogicalResourceId never changes. + let handler = async function() { + switch (event.RequestType) { + case "Update": + let oldAliases = new Set(event.OldResourceProperties.Aliases); + if (setEqual(oldAliases, aliases)) { + break; + } + await validateAliases(aliases, loadBalancerDNS); + await activate(aliases, loadBalancerDNS, loadBalancerHostedZoneID); + let unusedAliases = new Set([...oldAliases].filter(a => !aliases.has(a))); + await deactivate(unusedAliases, loadBalancerDNS, loadBalancerHostedZoneID); + break; + case "Create": + await validateAliases(aliases, loadBalancerDNS); + await activate(aliases, loadBalancerDNS, loadBalancerHostedZoneID); + break; + case "Delete": + await deactivate(aliases, loadBalancerDNS, loadBalancerHostedZoneID); + break; + default: + throw new Error(`Unsupported request type ${event.RequestType}`); + } + }; + + try { + await Promise.race([exports.deadlineExpired(), handler(),]); + await report(event, context, "SUCCESS", physicalResourceID); + } catch (err) { + console.log(`Caught error for service ${serviceName}: ${err.message}`); + await report(event, context, "FAILED", physicalResourceID, null, err.message); + } +}; + +/** + * Validate that the aliases are not in use. + * + * @param {Set} aliases for the service. + * @param {String} loadBalancerDNS the DNS of the service's load balancer. + * @throws error if at least one of the aliases is not valid. + */ +async function validateAliases(aliases, loadBalancerDNS) { + let promises = []; + + for (let alias of aliases) { + let {hostedZoneID, route53Client } = await domainResources(alias); + const promise = route53Client.listResourceRecordSets({ + HostedZoneId: hostedZoneID, + MaxItems: "1", + StartRecordName: alias, + }).promise().then(({ ResourceRecordSets: recordSet }) => { + if (!targetRecordExists(alias, recordSet)) { + return; + } + let aliasTarget = recordSet[0].AliasTarget; + if (aliasTarget && aliasTarget.DNSName.toLowerCase() === `${loadBalancerDNS.toLowerCase()}.`) { + return; // The record is an alias record and is in use by myself, hence valid. + } + if (aliasTarget) { + throw new Error(`Alias ${alias} is already in use by ${aliasTarget.DNSName}. This could be another load balancer of a different service.`); + } + throw new Error(`Alias ${alias} is already in use`); + }) + promises.push(promise); + } + await Promise.all(promises); +} + +/** + * Add A-records for the aliases + * @param {Set}aliases + * @param {String} loadBalancerDNS + * @param {String} loadBalancerHostedZone + * @returns {Promise} + */ +async function activate(aliases, loadBalancerDNS, loadBalancerHostedZone) { + let promises = []; + for (let alias of aliases) { + promises.push(activateAlias(alias, loadBalancerDNS, loadBalancerHostedZone)); + } + await Promise.all(promises); +} + +/** + * Add an A-record that points to the load balancer DNS as an alias target for the alias to its corresponding hosted zone. + * @param {String} alias + * @param {String} loadBalancerDNS + * @param {String} loadBalancerHostedZone + * @returns {Promise} + */ +async function activateAlias(alias, loadBalancerDNS, loadBalancerHostedZone) { + // NOTE: It has been validated that if the alias is in use, it is in use by the service itself. + // Therefore, an "UPSERT" will not overwrite a record that belongs to another service. + let changes = [{ + Action: "UPSERT", + ResourceRecordSet: { + Name: alias, + Type: "A", + AliasTarget: { + DNSName: loadBalancerDNS, + EvaluateTargetHealth: true, + HostedZoneId: loadBalancerHostedZone, + } + } + }]; + + let {hostedZoneID, route53Client} = await domainResources(alias); + let { ChangeInfo } = await route53Client.changeResourceRecordSets({ + ChangeBatch: { + Comment: `Upsert A-record for alias ${alias}`, + Changes: changes, + }, + HostedZoneId: hostedZoneID, + }).promise(); + + await route53Client.waitFor('resourceRecordSetsChanged', { + // Wait up to 5 minutes + $waiter: { + delay: DELAY_RECORD_SETS_CHANGE_IN_S, + maxAttempts: ATTEMPTS_RECORD_SETS_CHANGE, + }, + Id: ChangeInfo.Id, + }).promise(); +} + +/** + * + * @param {Set} aliases + * @param {String} loadBalancerDNS + * @param {String} loadBalancerHostedZoneID + * @returns {Promise} + */ +async function deactivate(aliases, loadBalancerDNS, loadBalancerHostedZoneID) { + let promises = []; + for (let alias of aliases) { + promises.push(deactivateAlias(alias, loadBalancerDNS, loadBalancerHostedZoneID)); + } + await Promise.all(promises); +} + +/** + * Remove the A-record of an alias that points to the load balancer DNS from its corresponding hosted zone. + * + * @param {String} alias + * @param {String} loadBalancerDNS + * @param {String} loadBalancerHostedZoneID + * @returns {Promise} + */ +async function deactivateAlias(alias, loadBalancerDNS, loadBalancerHostedZoneID) { + // NOTE: It has been validated that if the alias is in use, it is in use by the service itself. + // Therefore, an "UPSERT" will not overwrite a record that belongs to another service. + let changes = [{ + Action: "DELETE", + ResourceRecordSet: { + Name: alias, + Type: "A", + AliasTarget: { + DNSName: loadBalancerDNS, + EvaluateTargetHealth: true, + HostedZoneId: loadBalancerHostedZoneID, + } + } + }]; + + let {hostedZoneID, route53Client} = await domainResources(alias); + let changeResourceRecordSetsInput = { + ChangeBatch: { + Comment: `Delete the A-record for ${alias}`, + Changes: changes, + }, + HostedZoneId: hostedZoneID, + } + let changeInfo; + try { + ({ ChangeInfo: changeInfo } = await route53Client.changeResourceRecordSets(changeResourceRecordSetsInput).promise()); + } catch (e) { + let recordSetNotFoundErrMessageRegex = new RegExp(".*Tried to delete resource record set.*but it was not found.*"); + if (recordSetNotFoundErrMessageRegex.test(e.message)) { + return; // If we attempt to `DELETE` a record that doesn't exist, the job is already done, skip waiting. + } + + let recordSetNotMatchedErrMessageRegex = new RegExp(".*Tried to delete resource record set.*but the values provided do not match the current values.*") + if (recordSetNotMatchedErrMessageRegex.test(e.message)) { + // NOTE: The alias target, or record value is not exactly what we provided + // E.g. the alias target DNS name is another load balancer + // This service should not delete the A-record if it is not being pointed to. + // However, this is an unexpected situation, we should log this information. + console.log(`Received error when trying to delete A-record for ${alias}: ${e.message}. Perhaps the alias record isn't pointing to the load balancer ${loadBalancerDNS}.`) + return; + } + throw new Error(`delete record ${alias}: ` + e.message); + } + + await route53Client.waitFor('resourceRecordSetsChanged', { + // Wait up to 5 minutes + $waiter: { + delay: DELAY_RECORD_SETS_CHANGE_IN_S, + maxAttempts: ATTEMPTS_RECORD_SETS_CHANGE, + }, + Id: changeInfo.Id, + }).promise(); +} + +/** + * Validate if the exact record exits in the set of records. + * @param targetDomainName The domain name that the target record should have + * @param recordSet + * @returns {boolean} + */ +function targetRecordExists(targetDomainName, recordSet) { + if (!recordSet || recordSet.length === 0) { + return false; + } + return recordSet[0].Name === `${targetDomainName}.`; +} + +async function hostedZoneIDByName(domain) { + const { HostedZones } = await clients.app.route53() + .listHostedZonesByName({ + DNSName: domain, + MaxItems: "1", + }).promise(); + if (!HostedZones || HostedZones.length === 0) { + throw new Error( `Couldn't find any Hosted Zone with DNS name ${domainName}.`); + } + return HostedZones[0].Id.split("/").pop(); +} + +async function domainResources (alias) { + if (domainTypes.EnvDomainZone.regex.test(alias)) { + return { + domain: domainTypes.EnvDomainZone.domain, + route53Client: clients.env.route53(), + hostedZoneID: envHostedZoneID, + }; + } + if (domainTypes.AppDomainZone.regex.test(alias)) { + return { + domain: domainTypes.AppDomainZone.domain, + route53Client: clients.app.route53(), + hostedZoneID: await hostedZoneID.app(), + }; + } + if (domainTypes.RootDomainZone.regex.test(alias)) { + return { + domain: domainTypes.RootDomainZone.domain, + route53Client: clients.root.route53(), + hostedZoneID: await hostedZoneID.root(), + }; + } + throw new UnrecognizedDomainTypeError(`unrecognized domain type for ${alias}`); +} + +function setEqual(setA, setB) { + if (setA.size !== setB.size) { + return false; + } + + for (let elem of setA) { + if (!setB.has(elem)) { + return false; + } + } + return true; +} + +function UnrecognizedDomainTypeError(message = "") { + this.message = message; +} +UnrecognizedDomainTypeError.prototype = Object.create(Error.prototype, { + constructor: { + value: Error, + enumerable: false, + writable: true, + configurable: true + } +}); + + +exports.deadlineExpired = function () { + return new Promise(function (resolve, reject) { + setTimeout( + reject, + 14 * 60 * 1000 + 30 * 1000 /* 14.5 minutes*/, + new Error(`Lambda took longer than 14.5 minutes to update custom domain`) + ); + }); +}; + +exports.withSleep = function (s) { + sleep = s; +}; +exports.reset = function () { + sleep = defaultSleep; +}; +exports.withDeadlineExpired = function (d) { + exports.deadlineExpired = d; +}; +exports.attemptsValidationOptionsReady = ATTEMPTS_VALIDATION_OPTIONS_READY; diff --git a/cf-custom-resources/test/nlb-cert-validator-test.js b/cf-custom-resources/test/nlb-cert-validator-test.js new file mode 100644 index 00000000000..d3561331aa8 --- /dev/null +++ b/cf-custom-resources/test/nlb-cert-validator-test.js @@ -0,0 +1,880 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +"use strict"; + +const AWS = require("aws-sdk-mock"); +const LambdaTester = require("lambda-tester").noVersionCheck(); +const sinon = require("sinon"); +const nock = require("nock"); +let origLog = console.log; + +const { attemptsValidationOptionsReady } = require("../lib/nlb-cert-validator"); + +describe("DNS Certificate Validation And Custom Domains for NLB", () => { + // Mock requests. + const mockServiceName = "web"; + const mockEnvName = "mockEnv"; + const mockAppName = "mockApp"; + const mockDomainName = "mockDomain.com"; + const mockEnvHostedZoneID = "mockEnvHostedZoneID"; + const mockLBDNS = "mockLBDNS"; + const mockLBHostedZoneID = "mockLBHostedZoneID" + const mockResponseURL = "https://mock.com/"; + const mockRootDNSRole = "mockRootDNSRole" + + // Mock respond request. + function mockFailedRequest(expectedErrMessageRegex) { + return nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "FAILED" && + body.Reason.search(expectedErrMessageRegex) !== -1 + ); + }) + .reply(200); + } + + let handler, reset, withDeadlineExpired ; + beforeEach(() => { + // Prevent logging. + console.log = function () {}; + + // Reimport handlers so that the lazy loading does not fail the mocks. + // A description of the issue can be found here: https://github.com/dwyl/aws-sdk-mock/issues/206. + // This workaround follows the comment here: https://github.com/dwyl/aws-sdk-mock/issues/206#issuecomment-640418772. + jest.resetModules(); + AWS.setSDKInstance(require('aws-sdk')); + const imported = require("../lib/nlb-cert-validator"); + handler = imported.handler; + reset = imported.reset; + withDeadlineExpired = imported.withDeadlineExpired; + + // Mocks wait functions. + imported.withSleep(_ => { + return Promise.resolve(); + }); + withDeadlineExpired(_ => { + return new Promise(function (resolve, reject) {}); + }); + }); + + afterEach(() => { + // Restore logger + console.log = origLog; + AWS.restore(); + reset(); + }); + + describe("During CREATE with alias", () => { + const mockRequest = { + ResponseURL: mockResponseURL, + ResourceProperties: { + ServiceName: mockServiceName, + Aliases: ["dash-test.mockDomain.com", "a.mockApp.mockDomain.com", "b.mockEnv.mockApp.mockDomain.com"], + EnvName: mockEnvName, + AppName: mockAppName, + DomainName: mockDomainName, + LoadBalancerDNS: mockLBDNS, + LoadBalancerHostedZoneID: mockLBHostedZoneID, + EnvHostedZoneId: mockEnvHostedZoneID, + RootDNSRole: mockRootDNSRole, + }, + RequestType: "Create", + LogicalResourceId: "mockID", + }; + + // API call mocks. + const mockListHostedZonesByName = sinon.stub(); + const mockListResourceRecordSets = sinon.stub(); + const mockRequestCertificate = sinon.stub(); + const mockDescribeCertificate = sinon.stub(); + const mockChangeResourceRecordSets = sinon.stub(); + const mockWaitForRecordsChange = sinon.stub(); + const mockWaitForCertificateValidation = sinon.stub(); + const mockAppHostedZoneID = "mockAppHostedZoneID"; + const mockRootHostedZoneID = "mockRootHostedZoneID"; + + beforeEach(() => { + // Mock API default behavior. + mockListResourceRecordSets.resolves({ + "ResourceRecordSets": [] + }); + mockRequestCertificate.resolves({ + "CertificateArn": "mockCertArn", + }); + mockDescribeCertificate.resolves({ + "Certificate": { + "DomainValidationOptions": [{ + "ResourceRecord": { + Name: "mock-validate-default-cert", + Value: "mock-validate-default-cert-value", + Type: "mock-validate-default-cert-type" + }, + "DomainName": `${mockServiceName}-nlb.${mockEnvName}.${mockAppName}.${mockDomainName}`, + },{ + "ResourceRecord": { + Name: "mock-validate-alias-1", + Value: "mock-validate-alias-1-value", + Type: "mock-validate-alias-1-type" + }, + "DomainName": "dash-test.mockDomain.com", + },{ + "ResourceRecord": { + Name: "mock-validate-alias-2", + Value: "mock-validate-alias-2-value", + Type: "mock-validate-alias-2-type" + }, + "DomainName": "a.mockApp.mockDomain.com", + },{ + "ResourceRecord": { + Name: "mock-validate-alias-3", + Value: "mock-validate-alias-3-value", + Type: "mock-validate-alias-3-type" + }, + "DomainName": "b.mockEnv.mockApp.mockDomain.com", + }], + }, + }); + mockChangeResourceRecordSets.resolves({ ChangeInfo: {Id: "mockChangeID", }, }) + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockAppHostedZoneID + }] + }); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockRootHostedZoneID, + }] + }); + mockWaitForRecordsChange.resolves(); + mockWaitForCertificateValidation.resolves(); + }) + + afterEach(() => { + // Reset mocks call count. + mockListHostedZonesByName.reset(); + mockListResourceRecordSets.reset(); + mockRequestCertificate.reset(); + mockDescribeCertificate.reset(); + mockChangeResourceRecordSets.reset(); + }); + + test("unsupported action fails", () => { + let request = mockFailedRequest(/^Unsupported request type Unknown \(Log: .*\)$/); + return LambdaTester(handler) + .event({ + ResponseURL: mockResponseURL, + ResourceProperties: {}, + RequestType: "Unknown", + LogicalResourceId: "mockID", + }) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + + test("error if an alias is not valid", () => { + let request = mockFailedRequest(/^unrecognized domain type for Wow-this-domain-is-so-weird-that-it-does-not-work-at-all \(Log: .*\)$/); + return LambdaTester(handler) + .event({ + ResponseURL: mockResponseURL, + ResourceProperties: { + Aliases: ["Wow-this-domain-is-so-weird-that-it-does-not-work-at-all"], + }, + RequestType: "Create", + }) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + + test("error fetching app-level hosted zone ID", () => { + const mockListHostedZonesByName = sinon.stub(); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).rejects(new Error("some error")); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockRootHostedZoneID, + }] + }); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); + }); + }); + + test("error fetching root-level hosted zone ID", () => { + const mockListHostedZonesByName = sinon.stub(); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockAppHostedZoneID, + }] + }); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + + test("error validating aliases", () => { + const mockListResourceRecordSets = sinon.fake.rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListResourceRecordSets, 3); + }); + }); + + test("some aliases are in use by other service", () => { + const mockListResourceRecordSets = sinon.fake.resolves({ + "ResourceRecordSets": [{ + "AliasTarget": { + "DNSName": "other-lb-DNS", + }, + Name: "dash-test.mockDomain.com.", + }] + }); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + + let request = mockFailedRequest(/^Alias dash-test.mockDomain.com is already in use by other-lb-DNS. This could be another load balancer of a different service. \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); + sinon.assert.callCount(mockListResourceRecordSets, 3); + }); + }); + + test("fail to request a certificate", () => { + const mockRequestCertificate =sinon.fake.rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("ACM", "requestCertificate", mockRequestCertificate); + + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); + sinon.assert.callCount(mockListResourceRecordSets, 3); + sinon.assert.callCount(mockRequestCertificate, 1); + }); + }) + + test("timed out waiting for validation options to be ready", () => { + const mockDescribeCertificate = sinon.fake.resolves({ + "Certificate": { + "DomainValidationOptions": [{ + "ResourceRecord": {}, + "DomainName": "not the domain we want", + }], + }, + }); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("ACM", "requestCertificate", mockRequestCertificate); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + + + let request = mockFailedRequest(/^resource validation records are not ready after 10 tries \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); + sinon.assert.callCount(mockListResourceRecordSets, 3); + sinon.assert.callCount(mockRequestCertificate, 1); + sinon.assert.callCount(mockDescribeCertificate, attemptsValidationOptionsReady); + }); + }); + + test("error while waiting for validation options to be ready", () => { + const mockDescribeCertificate = sinon.fake.rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("ACM", "requestCertificate", mockRequestCertificate); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); + sinon.assert.callCount(mockListResourceRecordSets, 3); + sinon.assert.callCount(mockRequestCertificate, 1); + sinon.assert.callCount(mockDescribeCertificate, 1); + }); + }); + + test("fail to upsert validation record for an alias into hosted zone", () => { + const mockChangeResourceRecordSets = sinon.stub(); + mockChangeResourceRecordSets.rejects(new Error("some error")); + + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("ACM", "requestCertificate", mockRequestCertificate); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); + sinon.assert.callCount(mockListResourceRecordSets, 3); + sinon.assert.callCount(mockRequestCertificate, 1); + sinon.assert.callCount(mockDescribeCertificate, 1); + sinon.assert.callCount(mockChangeResourceRecordSets, 4); + sinon.assert.calledWithMatch(mockChangeResourceRecordSets, sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "mock-validate-default-cert")) + sinon.assert.calledWithMatch(mockChangeResourceRecordSets, sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "mock-validate-alias-1")) + }); + }); + + test("fail to wait for resource record sets change to be finished", () => { + const mockWaitFor = sinon.fake.rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("ACM", "requestCertificate", mockRequestCertificate); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitFor); + + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); + sinon.assert.callCount(mockListResourceRecordSets, 3); + sinon.assert.callCount(mockRequestCertificate, 1); + sinon.assert.callCount(mockDescribeCertificate, 1); + sinon.assert.callCount(mockChangeResourceRecordSets, 4); + sinon.assert.callCount(mockWaitFor, 4); + }); + }); + + test("fail to wait for certificate to be validated", () => { + const mockWaitForRecordsChange = sinon.stub(); + mockWaitForRecordsChange.withArgs("resourceRecordSetsChanged", sinon.match.has("Id", "mockChangeID")).resolves(); + const mockWaitForCertificateValidation = sinon.stub(); + mockWaitForCertificateValidation.withArgs('certificateValidated', sinon.match.has("CertificateArn", "mockCertArn")).rejects(new Error("some error")); + + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("ACM", "requestCertificate", mockRequestCertificate); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + AWS.mock("ACM", "waitFor", mockWaitForCertificateValidation); + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); + sinon.assert.callCount(mockListResourceRecordSets, 3); + sinon.assert.callCount(mockRequestCertificate, 1); + sinon.assert.callCount(mockDescribeCertificate, 1); + sinon.assert.callCount(mockChangeResourceRecordSets, 4); + sinon.assert.callCount(mockWaitForRecordsChange, 4); + sinon.assert.callCount(mockWaitForCertificateValidation, 1); + }); + }); + + test("lambda time out", () => { + withDeadlineExpired(_ => { + return new Promise(function (_, reject) { + reject(new Error("lambda time out error")); + }); + }); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("ACM", "requestCertificate", mockRequestCertificate); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", sinon.fake.resolves()); + AWS.mock("ACM", "waitFor", sinon.fake.resolves()); + + let request = mockFailedRequest(/^lambda time out error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + + test("successful operation", () => { + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("ACM", "requestCertificate", mockRequestCertificate); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + AWS.mock("ACM", "waitFor", mockWaitForCertificateValidation); + + // let request = mockFailedRequest(/^some error \(Log: .*\)$/); + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" && body.PhysicalResourceId === "mockCertArn" + ); + }) + .reply(200); + + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); + sinon.assert.callCount(mockListResourceRecordSets, 3); + sinon.assert.callCount(mockRequestCertificate, 1); + sinon.assert.callCount(mockDescribeCertificate, 1); + sinon.assert.callCount(mockChangeResourceRecordSets, 4); + sinon.assert.callCount(mockWaitForRecordsChange, 4); + sinon.assert.callCount(mockWaitForCertificateValidation, 1); + }); + }); + + }) + + describe("During DELETE", () => { + const mockRequest = { + ResponseURL: mockResponseURL, + ResourceProperties: { + ServiceName: mockServiceName, + Aliases: ["unused.mockDomain.com", "usedByNewCert.mockApp.mockDomain.com", "usedByOtherCert.mockApp.mockDomain.com", "usedByOtherService.mockEnv.mockApp.mockDomain.com"], + EnvName: mockEnvName, + AppName: mockAppName, + DomainName: mockDomainName, + LoadBalancerDNS: mockLBDNS, + LoadBalancerHostedZoneID: mockLBHostedZoneID, + EnvHostedZoneId: mockEnvHostedZoneID, + RootDNSRole: mockRootDNSRole, + }, + RequestType: "Delete", + LogicalResourceId: "mockID", + PhysicalResourceId: "arn:mockARNToDelete", + }; + + // API call mocks. + const mockListHostedZonesByName = sinon.stub(); + const mockGetResources = sinon.stub(); + const mockDescribeCertificate = sinon.stub(); + const mockListResourceRecordSets = sinon.stub(); + const mockDeleteCertificate = sinon.stub(); + const mockChangeResourceRecordSets = sinon.stub(); + const mockWaitForRecordsChange = sinon.stub(); + const mockAppHostedZoneID = "mockAppHostedZoneID"; + const mockRootHostedZoneID = "mockRootHostedZoneID"; + beforeEach(() => { + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockAppHostedZoneID + }] + }); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockRootHostedZoneID, + }] + }); + mockGetResources.resolves({ + ResourceTagMappingList: [{ ResourceARN: "arn:mockARNToDelete" }, { ResourceARN: "arn:mockARNInUse" }, { ResourceARN: "arn:mockARNInUse2" }], + }); + + mockDescribeCertificate.withArgs(sinon.match({ CertificateArn: "arn:mockARNToDelete" })).onFirstCall().resolves({ + Certificate: { + CertificateArn: "arn:mockARNToDelete", + DomainName: `${mockServiceName}-nlb.${mockEnvName}.${mockAppName}.${mockDomainName}`, + SubjectAlternativeNames: ["usedByOtherService.mockEnv.mockApp.mockDomain.com", "usedByOtherCert.mockApp.mockDomain.com", "unused.mockDomain.com", "usedByNewCert.mockApp.mockDomain.com", `${mockServiceName}-nlb.${mockEnvName}.${mockAppName}.${mockDomainName}`], + DomainValidationOptions: [{ + DomainName: "unused.mockDomain.com", + ResourceRecord: { + Name: "validate.unused.mockDomain.com", + Type: "CNAME", + Value: "validate.unused.mockDomain.com.v" + }, + },{ + DomainName: "usedByNewCert.mockApp.mockDomain.com", + ResourceRecord: { + Name: "validate.usedByNewCert.mockApp.mockDomain.com", + Type: "CNAME", + Value: "validate.usedByNewCert.mockApp.mockDomain.com.v" + }, + },{ + DomainName: "usedByOtherService.mockEnv.mockApp.mockDomain.com", + ResourceRecord: { + Name: "validate.usedByOtherService.mockEnv.mockApp.mockDomain.com", + Type: "CNAME", + Value: "validate.usedByOtherService.mockEnv.mockApp.mockDomain.com.v" + } + },{ + DomainName: "usedByOtherCert.mockApp.mockDomain.com", + ResourceRecord: { + Name: "validate.usedByOtherCert.mockApp.mockDomain.com", + Type: "CNAME", + Value: "validate.usedByOtherCert.mockApp.mockDomain.com.v" + } + },{ + DomainName: "random.unrecognized.domain", + ResourceRecord: { + Name: "validate.random.unrecognized.domain", + Type: "CNAME", + Value: "validate.random.unrecognized.domain.v" + } + },{ + DomainName: `${mockServiceName}-nlb.${mockEnvName}.${mockAppName}.${mockDomainName}`, + }], + } + }); + mockDescribeCertificate.withArgs(sinon.match({ CertificateArn: "arn:mockARNInUse" })).resolves({ + Certificate: { + CertificateArn: "mockARNInUse", + DomainName: `${mockServiceName}-nlb.${mockEnvName}.${mockAppName}.${mockDomainName}`, + SubjectAlternativeNames: ["usedByNewCert.mockApp.mockDomain.com", "other.mockDomain.com", `${mockServiceName}-nlb.${mockEnvName}.${mockAppName}.${mockDomainName}`], + DomainValidationOptions: [{ + DomainName: "usedByNewCert.mockApp.mockDomain.com", + ResourceRecord: { + Name: "validate.usedByNewCert.mockApp.mockDomain.com", + Type: "CNAME", + Value: "validate.usedByNewCert.mockApp.mockDomain.com.v" + }, + },{ + DomainName: "other.mockDomain.com", + ResourceRecord: { + Name: "validate.other.mockDomain.com", + Type: "CNAME", + Value: "validate.other.mockDomain.com.v" + }, + },{ + DomainName: `${mockServiceName}-nlb.${mockEnvName}.${mockAppName}.${mockDomainName}`, + ResourceRecord: { + Name: "validate.canonical.default.cert.domain", + Type: "CNAME", + Value: "validate.canonical.default.cert.domain.v" + } + }], + } + }); + mockDescribeCertificate.withArgs(sinon.match({ CertificateArn: "arn:mockARNInUse2" })).resolves({ + Certificate: { + CertificateArn: "mockARNInUse2", + DomainName: `some.domain.name.that.is.not.default.hence.not.created.by.copilot`, + SubjectAlternativeNames: ["usedByOtherCert.mockApp.mockDomain.com", "other.mockDomain.com", `some.domain.name.that.is.not.default.hence.not.created.by.copilot`], + DomainValidationOptions: [{ + DomainName: "usedByOtherCert.mockApp.mockDomain.com", + ResourceRecord: { + Name: "validate.usedByOtherCert.mockApp.mockDomain.com", + Type: "CNAME", + Value: "validate.usedByOtherCert.mockApp.mockDomain.com.v" + } + },{ + DomainName: "other.mockDomain.com", + ResourceRecord: { + Name: "validate.other.mockDomain.com", + Type: "CNAME", + Value: "validate.other.mockDomain.com.v" + }, + },{ + DomainName: "some.domain.name.that.is.not.default.hence.not.created.by.copilot", + ResourceRecord: { + Name: "validate.some.domain.name.that.is.not.default.hence.not.created.by.copilot", + Type: "CNAME", + Value: "validate.some.domain.name.that.is.not.default.hence.not.created.by.copilot.v" + }, + }], + } + }); + mockListResourceRecordSets.withArgs(sinon.match({ HostedZoneId: "mockRootHostedZoneID", StartRecordName: "unused.mockDomain.com"})).resolves({ + ResourceRecordSets: [{ + Name: "unused.mockDomain.com.", + AliasTarget: { + DNSName: `${mockLBDNS}.`, + } + }], + }); + mockListResourceRecordSets.withArgs(sinon.match({ HostedZoneId: mockEnvHostedZoneID, StartRecordName: "usedByOtherService.mockEnv.mockApp.mockDomain.com"})).resolves({ + ResourceRecordSets: [{ + Name: "usedByOtherService.mockEnv.mockApp.mockDomain.com.", + AliasTarget: { + DNSName: "other.service.dns.name.", + } + }], + }); + mockDeleteCertificate.withArgs({ CertificateArn: "arn:mockARNToDelete"}).resolves(); + mockChangeResourceRecordSets.withArgs(sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "validate.unused.mockDomain.com")).resolves({ + ChangeInfo: {Id: "mockID",}, + }); + mockWaitForRecordsChange.withArgs("resourceRecordSetsChanged", sinon.match.has("Id", "mockID")).resolves(); + }); + + afterEach(() => { + // Reset mocks call count. + mockListHostedZonesByName.reset(); + mockGetResources.reset(); + mockDescribeCertificate.reset(); + mockListResourceRecordSets.reset(); + mockDeleteCertificate.reset(); + mockDeleteCertificate.reset(); + }); + + test("error retrieving service certificate by tags", () => { + const mockGetResources = sinon.stub().rejects(new Error("some error")); + AWS.mock("ResourceGroupsTaggingAPI", "getResources", mockGetResources); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.calledWith(mockGetResources, { + TagFilters: [ + { + Key: "copilot-application", + Values: [mockAppName], + }, + { + Key: "copilot-environment", + Values: [mockEnvName], + }, + { + Key: "copilot-service", + Values: [mockServiceName], + } + ], + ResourceTypeFilters: ["acm:certificate"] + }); + }); + }); + + test("error describing certificate", () => { + const mockDescribeCertificate = sinon.stub().rejects(new Error("some error")); + AWS.mock("ResourceGroupsTaggingAPI", "getResources", mockGetResources); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockGetResources, 1); + sinon.assert.callCount(mockDescribeCertificate, 3); + }); + }); + + test("error listing resource record set", () => { + const mockListResourceRecordSets = sinon.stub().rejects(new Error("some error")); + AWS.mock("ResourceGroupsTaggingAPI", "getResources", mockGetResources); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockGetResources, 1); + sinon.assert.callCount(mockDescribeCertificate, 3); + }); + }); + + test("error removing validation record for an alias into hosted zone", () => { + const mockChangeResourceRecordSets = sinon.stub(); + mockChangeResourceRecordSets.withArgs(sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "validate.unused.mockDomain.com")).rejects(new Error("some error")); + AWS.mock("ResourceGroupsTaggingAPI", "getResources", mockGetResources); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + let request = mockFailedRequest(/^delete record validate.unused.mockDomain.com: some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockGetResources, 1); + sinon.assert.callCount(mockDescribeCertificate, 3); + sinon.assert.callCount(mockChangeResourceRecordSets, 1); // Only one validation option is to be deleted. + }); + }); + + test("error waiting for resource record sets change to be finished", () => { + const mockWaitForRecordsChange = sinon.fake.rejects(new Error("some error")); + AWS.mock("ResourceGroupsTaggingAPI", "getResources", mockGetResources); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockGetResources, 1); + sinon.assert.callCount(mockDescribeCertificate, 3); + sinon.assert.callCount(mockChangeResourceRecordSets, 1); // Only one validation option is to be deleted. + sinon.assert.callCount(mockWaitForRecordsChange, 1); + sinon.assert.calledWithMatch(mockChangeResourceRecordSets, sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "validate.unused.mockDomain.com")) + }); + }); + + test("error waiting for certificate to be unused", () => { + mockDescribeCertificate.resolves({ + Certificate: { + InUseBy: ["inuse"], + } + }); + AWS.mock("ResourceGroupsTaggingAPI", "getResources", mockGetResources); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + let request = mockFailedRequest(/^Certificate still in use after checking for 12 attempts. \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockGetResources, 1); + sinon.assert.callCount(mockDescribeCertificate, 15); // 3 call to list unused options, 12 calls to wait for the certificate to be unused. + }); + }); + + test("error deleting certificate", () => { + const mockDeleteCertificate = sinon.stub().rejects(new Error("some error")); + mockDescribeCertificate.resolves({ + Certificate: { + InUseBy: [], + } + }); + AWS.mock("ResourceGroupsTaggingAPI", "getResources", mockGetResources); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + AWS.mock("ACM", "deleteCertificate", mockDeleteCertificate); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockGetResources, 1); + sinon.assert.callCount(mockDescribeCertificate, 4); // 3 call to list unused options, 1 calls to find out that the certificate is unused. + sinon.assert.callCount(mockDeleteCertificate, 1); + }); + }); + + test("do not error out if a validation record is not found", () => { + const mockChangeResourceRecordSets = sinon.fake.rejects(new Error("Tried to delete resource record set [name='validate.unused.mockDomain.com', type='CNAME'] but it was not found")); + // Since we don't expect the above error to interrupt the process, we need to mock the actions following `ChangeResourceRecordSets` as well. + let resourceNotFoundErr = new Error("some error"); + resourceNotFoundErr.name = "ResourceNotFoundException"; + mockDescribeCertificate.rejects(resourceNotFoundErr); + AWS.mock("ResourceGroupsTaggingAPI", "getResources", mockGetResources); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" + ); + }) + .reply(200); return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockGetResources, 1); + sinon.assert.callCount(mockDescribeCertificate, 4); // 3 call to list unused options, 1 calls to find out that the certificate is already not found. + sinon.assert.callCount(mockChangeResourceRecordSets, 1); // Only one validation option is to be deleted. + + }); + }); + + test("do not error out if certificate is not found while waiting for it to be unused", () => { + let resourceNotFoundErr = new Error("some error"); + resourceNotFoundErr.name = "ResourceNotFoundException"; + mockDescribeCertificate.rejects(resourceNotFoundErr); + AWS.mock("ResourceGroupsTaggingAPI", "getResources", mockGetResources); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("ACM", "deleteCertificate", mockDeleteCertificate); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" + ); + }) + .reply(200); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockGetResources, 1); + sinon.assert.callCount(mockDescribeCertificate, 4); // 3 call to list unused options, 1 calls to find out that the certificate is already not found. + }); + }); + + test("do not error out if certificate is not found while deleting", () => { + mockDescribeCertificate.resolves({ + Certificate: { + InUseBy: [], + } + }); + let resourceNotFoundErr = new Error("some error"); + resourceNotFoundErr.name = "ResourceNotFoundException"; + const mockDeleteCertificate = sinon.stub().rejects(resourceNotFoundErr); + AWS.mock("ResourceGroupsTaggingAPI", "getResources", mockGetResources); + AWS.mock("ACM", "describeCertificate", mockDescribeCertificate); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("ACM", "deleteCertificate", mockDeleteCertificate); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" + ); + }) + .reply(200); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockGetResources, 1); + sinon.assert.callCount(mockDescribeCertificate, 4); // 3 call to list unused options, 1 calls to find out that the certificate is unused. + }); + }); + }) + +}); + diff --git a/cf-custom-resources/test/nlb-custom-domain-test.js b/cf-custom-resources/test/nlb-custom-domain-test.js new file mode 100644 index 00000000000..87b8e73c7c4 --- /dev/null +++ b/cf-custom-resources/test/nlb-custom-domain-test.js @@ -0,0 +1,688 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +"use strict"; + +const AWS = require("aws-sdk-mock"); +const LambdaTester = require("lambda-tester").noVersionCheck(); +const sinon = require("sinon"); +const nock = require("nock"); +let origLog = console.log; + +const { attemptsValidationOptionsReady } = require("../lib/nlb-custom-domain"); + +describe("DNS Certificate Validation And Custom Domains for NLB", () => { + // Mock requests. + const mockServiceName = "web"; + const mockEnvName = "mockEnv"; + const mockAppName = "mockApp"; + const mockDomainName = "mockDomain.com"; + const mockEnvHostedZoneID = "mockEnvHostedZoneID"; + const mockLBDNS = "mockLBDNS"; + const mockLBHostedZoneID = "mockLBHostedZoneID" + const mockResponseURL = "https://mock.com/"; + const mockRootDNSRole = "mockRootDNSRole" + + // Mock respond request. + function mockFailedRequest(expectedErrMessageRegex) { + return nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "FAILED" && + body.Reason.search(expectedErrMessageRegex) !== -1 + ); + }) + .reply(200); + } + + let handler, reset, withDeadlineExpired ; + beforeEach(() => { + // Prevent logging. + console.log = function () {}; + + // Reimport handlers so that the lazy loading does not fail the mocks. + // A description of the issue can be found here: https://github.com/dwyl/aws-sdk-mock/issues/206. + // This workaround follows the comment here: https://github.com/dwyl/aws-sdk-mock/issues/206#issuecomment-640418772. + jest.resetModules(); + AWS.setSDKInstance(require('aws-sdk')); + const imported = require("../lib/nlb-custom-domain"); + handler = imported.handler; + reset = imported.reset; + withDeadlineExpired = imported.withDeadlineExpired; + + // Mocks wait functions. + imported.withSleep(_ => { + return Promise.resolve(); + }); + withDeadlineExpired(_ => { + return new Promise(function (resolve, reject) {}); + }); + }); + + afterEach(() => { + // Restore logger + console.log = origLog; + AWS.restore(); + reset(); + }); + + describe("During CREATE with alias", () => { + const mockRequest = { + ResponseURL: mockResponseURL, + ResourceProperties: { + ServiceName: mockServiceName, + Aliases: ["dash-test.mockDomain.com", "a.mockApp.mockDomain.com", "b.mockEnv.mockApp.mockDomain.com"], + EnvName: mockEnvName, + AppName: mockAppName, + DomainName: mockDomainName, + LoadBalancerDNS: mockLBDNS, + LoadBalancerHostedZoneID: mockLBHostedZoneID, + EnvHostedZoneId: mockEnvHostedZoneID, + RootDNSRole: mockRootDNSRole, + }, + RequestType: "Create", + LogicalResourceId: "mockID", + }; + + // API call mocks. + const mockListHostedZonesByName = sinon.stub(); + const mockListResourceRecordSets = sinon.stub(); + const mockChangeResourceRecordSets = sinon.stub(); + const mockWaitForRecordsChange = sinon.stub(); + const mockAppHostedZoneID = "mockAppHostedZoneID"; + const mockRootHostedZoneID = "mockRootHostedZoneID"; + + beforeEach(() => { + // Mock API default behavior. + mockListResourceRecordSets.resolves({ + "ResourceRecordSets": [] + }); + mockChangeResourceRecordSets.resolves({ ChangeInfo: {Id: "mockChangeID", }, }) + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockAppHostedZoneID + }] + }); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockRootHostedZoneID, + }] + }); + mockWaitForRecordsChange.resolves(); + }) + + afterEach(() => { + // Reset mocks call count. + mockListHostedZonesByName.reset(); + mockListResourceRecordSets.reset(); + mockChangeResourceRecordSets.reset(); + mockWaitForRecordsChange.reset(); + }); + + test("unsupported action fails", () => { + let request = mockFailedRequest(/^Unsupported request type Unknown \(Log: .*\)$/); + return LambdaTester(handler) + .event({ + ResponseURL: mockResponseURL, + ResourceProperties: {}, + RequestType: "Unknown", + LogicalResourceId: "mockID", + }) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + + test("error if an alias is not valid", () => { + let request = mockFailedRequest(/^unrecognized domain type for Wow-this-domain-is-so-weird-that-it-does-not-work-at-all \(Log: .*\)$/); + return LambdaTester(handler) + .event({ + ResponseURL: mockResponseURL, + ResourceProperties: { + Aliases: ["Wow-this-domain-is-so-weird-that-it-does-not-work-at-all"], + }, + RequestType: "Create", + }) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + + test("error fetching app-level hosted zone ID", () => { + const mockListHostedZonesByName = sinon.stub(); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).rejects(new Error("some error")); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockRootHostedZoneID, + }] + }); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); + }); + }); + + test("error fetching root-level hosted zone ID", () => { + const mockListHostedZonesByName = sinon.stub(); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockAppHostedZoneID, + }] + }); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + + test("error validating aliases", () => { + const mockListResourceRecordSets = sinon.fake.rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListResourceRecordSets, 3); // 1 call for each alias; 3 aliases in total. + }); + }); + + test("some aliases are in use by other service", () => { + const mockListResourceRecordSets = sinon.fake.resolves({ + "ResourceRecordSets": [{ + "AliasTarget": { + "DNSName": "other-lb-DNS", + }, + Name: "dash-test.mockDomain.com.", + }] + }); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + + let request = mockFailedRequest(/^Alias dash-test.mockDomain.com is already in use by other-lb-DNS. This could be another load balancer of a different service. \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 call for each alias that is not env-level; there are 2 such aliases. + sinon.assert.callCount(mockListResourceRecordSets, 3); // 1 call for each alias; 3 aliases in total. + }); + }); + + test("fail to upsert A-record for an alias into hosted zone", () => { + const mockChangeResourceRecordSets = sinon.stub(); + mockChangeResourceRecordSets.withArgs(sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "dash-test.mockDomain.com")).rejects(new Error("some error")); + mockChangeResourceRecordSets.resolves({ChangeInfo: {Id: "mockID",},}); + + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 call for each alias that is not env-level; there are 2 such aliases. + sinon.assert.callCount(mockListResourceRecordSets, 3); // 1 call for each alias; 3 aliases in total. + sinon.assert.callCount(mockChangeResourceRecordSets, 3); // 1 call for each alias; 3 aliases in total. + sinon.assert.alwaysCalledWithMatch(mockChangeResourceRecordSets, sinon.match.hasNested("ChangeBatch.Changes[0].Action", "UPSERT")) + }); + }); + + test("fail to wait for resource record sets change to be finished", () => { + const mockWaitFor = sinon.fake.rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitFor); + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 call for each alias that is not env-level; there are 2 such aliases. + sinon.assert.callCount(mockListResourceRecordSets, 3); // 1 call for each alias; 3 aliases in total. + sinon.assert.callCount(mockChangeResourceRecordSets, 3); // 1 call for each alias; 3 aliases in total. + sinon.assert.callCount(mockWaitFor, 3); // 1 call for each alias; 3 aliases in total. + }); + }); + + test("lambda time out", () => { + withDeadlineExpired(_ => { + return new Promise(function (_, reject) { + reject(new Error("lambda time out error")); + }); + }); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", sinon.fake.resolves()); + AWS.mock("ACM", "waitFor", sinon.fake.resolves()); + + let request = mockFailedRequest(/^lambda time out error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + + test("successful operation", () => { + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + // let request = mockFailedRequest(/^some error \(Log: .*\)$/); + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" && body.PhysicalResourceId === "mockID" + ); + }) + .reply(200); + + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 call for each alias that is not env-level; there are 2 such aliases. + sinon.assert.callCount(mockListResourceRecordSets, 3); // 1 call for each alias; 3 aliases in total. + sinon.assert.callCount(mockChangeResourceRecordSets, 3); // 1 call for each alias; 3 aliases in total. + sinon.assert.callCount(mockWaitForRecordsChange, 3); // 1 call for each alias; 3 aliases in total. + sinon.assert.alwaysCalledWithMatch(mockChangeResourceRecordSets, sinon.match.hasNested("ChangeBatch.Changes[0].Action", "UPSERT")) + }); + }); + + }) + + describe("During DELETE", () => { + const mockRequest = { + ResponseURL: mockResponseURL, + ResourceProperties: { + ServiceName: mockServiceName, + Aliases: ["a.mockDomain.com", "b.mockApp.mockDomain.com", "c.mockEnv.mockApp.mockDomain.com"], + EnvName: mockEnvName, + AppName: mockAppName, + DomainName: mockDomainName, + LoadBalancerDNS: mockLBDNS, + LoadBalancerHostedZoneID: mockLBHostedZoneID, + EnvHostedZoneId: mockEnvHostedZoneID, + RootDNSRole: mockRootDNSRole, + }, + RequestType: "Delete", + LogicalResourceId: "mockID", + PhysicalResourceId: "arn:mockARNToDelete", + }; + + // API call mocks. + const mockListHostedZonesByName = sinon.stub(); + const mockChangeResourceRecordSets = sinon.stub(); + const mockWaitForRecordsChange = sinon.stub(); + const mockAppHostedZoneID = "mockAppHostedZoneID"; + const mockRootHostedZoneID = "mockRootHostedZoneID"; + beforeEach(() => { + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockAppHostedZoneID + }] + }); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockRootHostedZoneID, + }] + }); + mockChangeResourceRecordSets.withArgs(sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "a.mockDomain.com")).resolves({ + ChangeInfo: {Id: "mockID",}, + }); + mockChangeResourceRecordSets.withArgs(sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "b.mockApp.mockDomain.com")).resolves({ + ChangeInfo: {Id: "mockID",}, + }); + mockChangeResourceRecordSets.withArgs(sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "c.mockEnv.mockApp.mockDomain.com")).resolves({ + ChangeInfo: {Id: "mockID",}, + }); + mockWaitForRecordsChange.withArgs("resourceRecordSetsChanged", sinon.match.has("Id", "mockID")).resolves(); + }); + + afterEach(() => { + // Reset mocks call count. + mockListHostedZonesByName.reset(); + mockChangeResourceRecordSets.reset(); + mockWaitForRecordsChange.reset(); + }); + + test("error removing A-record for an alias into hosted zone", () => { + const mockChangeResourceRecordSets = sinon.stub(); + mockChangeResourceRecordSets.withArgs(sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "a.mockDomain.com")).rejects(new Error("some error")); + mockChangeResourceRecordSets.resolves({ChangeInfo: {Id: "mockID",},}); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + let request = mockFailedRequest(/^delete record a.mockDomain.com: some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 call for each non-environment-level alias; there are 2 such aliases. + sinon.assert.callCount(mockChangeResourceRecordSets, 3); // 1 call for each alias; there are 3 aliases. + sinon.assert.alwaysCalledWithMatch(mockChangeResourceRecordSets, sinon.match.hasNested("ChangeBatch.Changes[0].Action", "DELETE")) + }); + }); + + test("error waiting for resource record sets change to be finished", () => { + const mockWaitForRecordsChange = sinon.fake.rejects(new Error("some error")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + let request = mockFailedRequest(/^some error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 call for each non-environment-level alias; there are 2 such aliases. + sinon.assert.callCount(mockChangeResourceRecordSets, 3); // 1 call for each alias; there are 3 aliases. + sinon.assert.callCount(mockWaitForRecordsChange, 3); // 1 call for each alias; there are 3 aliases. + }); + }); + + test("do not error out if an A-record is not found", () => { + const mockChangeResourceRecordSets = sinon.fake.rejects(new Error("Tried to delete resource record set [name='A.mockDomain.com', type='A'] but it was not found")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" + ); + }) + .reply(200); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 call for each non-environment-level alias; there are 2 such aliases. + sinon.assert.callCount(mockChangeResourceRecordSets, 3); // 1 call for each alias; there are 3 aliases. + sinon.assert.callCount(mockWaitForRecordsChange, 0); // Exited early when changeResourceRecordSets returns the not found error. + }); + }); + + test("do not error out if an A-record's value doesn't match", () => { + const mockChangeResourceRecordSets = sinon.fake.rejects(new Error("Tried to delete resource record set [name='A.mockDomain.com', type='A'] but but the values provided do not match the current values")); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" + ); + }) + .reply(200); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 call for each non-environment-level alias; there are 2 such aliases. + sinon.assert.callCount(mockChangeResourceRecordSets, 3); // 1 call for each alias; there are 3 aliases. + sinon.assert.callCount(mockWaitForRecordsChange, 0); // Exited early when changeResourceRecordSets returns the not found error. + }); + }); + + test("lambda time out", () => { + withDeadlineExpired(_ => { + return new Promise(function (_, reject) { + reject(new Error("lambda time out error")); + }); + }); + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + let request = mockFailedRequest(/^lambda time out error \(Log: .*\)$/); + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + + test("successful operation", () => { + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" && body.PhysicalResourceId === "mockID" + ); + }) + .reply(200); + + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 call for each alias that is not env-level; there are 2 such aliases. + sinon.assert.callCount(mockChangeResourceRecordSets, 3); // 1 call for each alias; 3 aliases in total. + sinon.assert.callCount(mockWaitForRecordsChange, 3); // 1 call for each alias; 3 aliases in total. + sinon.assert.alwaysCalledWithMatch(mockChangeResourceRecordSets, sinon.match.hasNested("ChangeBatch.Changes[0].Action", "DELETE")) + }); + }); + }) + + describe("During UPDATE", () => { + let mockRequest; + + // API call mocks. + const mockListHostedZonesByName = sinon.stub(); + const mockListResourceRecordSets = sinon.stub(); + const mockChangeResourceRecordSets = sinon.stub(); + const mockWaitForRecordsChange = sinon.stub(); + const mockAppHostedZoneID = "mockAppHostedZoneID"; + const mockRootHostedZoneID = "mockRootHostedZoneID"; + + beforeEach(() => { + // Reset mockRequest. + mockRequest = { + ResponseURL: mockResponseURL, + ResourceProperties: { + ServiceName: mockServiceName, + EnvName: mockEnvName, + AppName: mockAppName, + DomainName: mockDomainName, + LoadBalancerDNS: mockLBDNS, + LoadBalancerHostedZoneID: mockLBHostedZoneID, + EnvHostedZoneId: mockEnvHostedZoneID, + RootDNSRole: mockRootDNSRole, + }, + OldResourceProperties: { + ServiceName: mockServiceName, + EnvName: mockEnvName, + AppName: mockAppName, + DomainName: mockDomainName, + LoadBalancerDNS: mockLBDNS, + LoadBalancerHostedZoneID: mockLBHostedZoneID, + EnvHostedZoneId: mockEnvHostedZoneID, + RootDNSRole: mockRootDNSRole, + }, + RequestType: "Update", + LogicalResourceId: "mockID", + }; + + // Mock API default behavior. + mockListResourceRecordSets.resolves({ + "ResourceRecordSets": [] + }); + mockChangeResourceRecordSets.resolves({ ChangeInfo: {Id: "mockChangeID", }, }) + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockApp.mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockAppHostedZoneID + }] + }); + mockListHostedZonesByName.withArgs(sinon.match.has("DNSName", "mockDomain.com")).resolves({ + HostedZones: [{ + Id: mockRootHostedZoneID, + }] + }); + mockWaitForRecordsChange.resolves(); + }) + + afterEach(() => { + // Reset mocks call count. + mockListHostedZonesByName.reset(); + mockListResourceRecordSets.reset(); + mockChangeResourceRecordSets.reset(); + mockWaitForRecordsChange.reset(); + }); + + test("do nothing if the new aliases are exactly the same as the old one", () => { + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); + + mockRequest.ResourceProperties.Aliases = ["a.mockDomain.com", "b.mockDomain.com", "b.mockDomain.com"]; + mockRequest.OldResourceProperties.Aliases = ["b.mockDomain.com", "a.mockDomain.com", "a.mockDomain.com"]; + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" && body.PhysicalResourceId === "mockID" + ); + }) + .reply(200); + + + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + // Called nothing. + sinon.assert.callCount(mockListResourceRecordSets, 0); + sinon.assert.callCount(mockListHostedZonesByName, 0); + sinon.assert.callCount(mockChangeResourceRecordSets, 0); + sinon.assert.callCount(mockWaitForRecordsChange, 0); + }); + }); + + test("new aliases that only add additional aliases to the old aliases, without deletion", () => { + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); // Calls to validate aliases. + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); // Calls to upsert the A-records. + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); // Calls to wait for the changes. + + mockRequest.ResourceProperties.Aliases = ["a.mockDomain.com", "b.mockApp.mockDomain.com", "c.mockEnv.mockApp.mockDomain.com"]; + mockRequest.OldResourceProperties.Aliases = ["a.mockDomain.com"]; + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" && body.PhysicalResourceId === "mockID" + ); + }) + .reply(200); + + + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 calls to each non-env-level aliases; there are 2 such aliases. + sinon.assert.callCount(mockListResourceRecordSets, 3); // 1 call to each alias to validate its ownership; there are 3 aliases. + sinon.assert.callCount(mockChangeResourceRecordSets, 3); // 1 call to each alias to upsert its A-record; there are 3 aliases. + sinon.assert.callCount(mockWaitForRecordsChange, 3);// 1 call to each alias after upserting A-record; there are 3 aliases. + }); + }); + + test("new aliases that only delete some aliases from the old aliases, without addition", () => { + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); // Calls to validate aliases. + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); // Calls to upsert the A-records. + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); // Calls to wait for the changes. + + mockRequest.ResourceProperties.Aliases = ["a.mockDomain.com"]; + mockRequest.OldResourceProperties.Aliases = ["a.mockDomain.com", "b.mockApp.mockDomain.com", "c.mockEnv.mockApp.mockDomain.com"]; + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" && body.PhysicalResourceId === "mockID" + ); + }) + .reply(200); + + + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 calls to each non-env-level aliases; there are 2 such aliases. + sinon.assert.callCount(mockChangeResourceRecordSets, 3); // 1 call to upsert the alias, 2 calls to remove unused aliases. + sinon.assert.callCount(mockWaitForRecordsChange, 3);// 1 call following each `ChangeResourceRecordSets`. + + // The following calls are made to add aliases. + // Although the aliases already exist (in `OldResourceProperties`), we repeat these operations anyway just to be sure. + sinon.assert.callCount(mockListResourceRecordSets, 1); // 1 call to each alias to validate its ownership; there are 1 alias. + sinon.assert.calledWithMatch(mockChangeResourceRecordSets.getCall(0), sinon.match.hasNested("ChangeBatch.Changes[0].Action", "UPSERT"),) + sinon.assert.calledWithMatch(mockChangeResourceRecordSets.getCall(0), sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "a.mockDomain.com"),) + + // The following calls are made to remove aliases. + sinon.assert.calledWithMatch(mockChangeResourceRecordSets.getCall(1), sinon.match.hasNested("ChangeBatch.Changes[0].Action", "DELETE")) + sinon.assert.calledWithMatch(mockChangeResourceRecordSets.getCall(2), sinon.match.hasNested("ChangeBatch.Changes[0].Action", "DELETE")) + sinon.assert.calledWithMatch(mockChangeResourceRecordSets, sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "b.mockApp.mockDomain.com"),) + sinon.assert.calledWithMatch(mockChangeResourceRecordSets, sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "c.mockEnv.mockApp.mockDomain.com"),) + }); + }); + + test("new aliases that both add to and remove from the aliases", () => { + AWS.mock("Route53", "listHostedZonesByName", mockListHostedZonesByName); + AWS.mock("Route53", "listResourceRecordSets", mockListResourceRecordSets); // Calls to validate aliases. + AWS.mock("Route53", "changeResourceRecordSets", mockChangeResourceRecordSets); // Calls to upsert the A-records. + AWS.mock("Route53", "waitFor", mockWaitForRecordsChange); // Calls to wait for the changes. + + mockRequest.ResourceProperties.Aliases = ["has-always-been.mockApp.mockDomain.com", "new.mockEnv.mockApp.mockDomain.com"]; + mockRequest.OldResourceProperties.Aliases = ["has-always-been.mockApp.mockDomain.com", "unused.mockDomain.com"]; + let request = nock(mockResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" && body.PhysicalResourceId === "mockID" + ); + }) + .reply(200); + + + return LambdaTester(handler) + .event(mockRequest) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + sinon.assert.callCount(mockListHostedZonesByName, 2); // 1 calls to each non-env-level aliases; there are 2 such aliases. + sinon.assert.callCount(mockChangeResourceRecordSets, 3); // 2 call to upsert the alias, 1 calls to remove unused aliases. + sinon.assert.callCount(mockWaitForRecordsChange, 3);// 1 call following each `ChangeResourceRecordSets`. + + // The following calls are made to add aliases. + sinon.assert.callCount(mockListResourceRecordSets, 2); // 1 call to each alias to validate its ownership; there are 2 alias. + sinon.assert.calledWithMatch(mockChangeResourceRecordSets.getCall(0), sinon.match.hasNested("ChangeBatch.Changes[0].Action", "UPSERT"),) + sinon.assert.calledWithMatch(mockChangeResourceRecordSets.getCall(1), sinon.match.hasNested("ChangeBatch.Changes[0].Action", "UPSERT"),) + sinon.assert.calledWithMatch(mockChangeResourceRecordSets, sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "has-always-been.mockApp.mockDomain.com"),) + sinon.assert.calledWithMatch(mockChangeResourceRecordSets, sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "new.mockEnv.mockApp.mockDomain.com"),) + + // The following calls are made to remove aliases. + sinon.assert.calledWithMatch(mockChangeResourceRecordSets.getCall(2), sinon.match.hasNested("ChangeBatch.Changes[0].Action", "DELETE")) + sinon.assert.calledWithMatch(mockChangeResourceRecordSets.getCall(2), sinon.match.hasNested("ChangeBatch.Changes[0].ResourceRecordSet.Name", "unused.mockDomain.com"),) + }); + }); + }) + +}); +