diff --git a/README.md b/README.md index 03dd48d9..d5d96c21 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ and run the gateway. ``` common/ contains files used by both NGINX OSS and Plus configurations + etc/nginx/include/ + awscredentials.js common library to read and write credentials + s3gateway.js common library to integrate the s3 storage from NGINX OSS and Plus + utils.js common library to be reused by all of NJS codebases deployments/ contains files used for deployment technologies such as CloudFormation docs/ contains documentation about the project diff --git a/common/etc/nginx/include/awscredentials.js b/common/etc/nginx/include/awscredentials.js new file mode 100644 index 00000000..417a04d8 --- /dev/null +++ b/common/etc/nginx/include/awscredentials.js @@ -0,0 +1,178 @@ +/* + * Copyright 2023 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import utils from "./utils.js"; + +const fs = require('fs'); + +/** + * Get the current session token from either the instance profile credential + * cache or environment variables. + * + * @param r {Request} HTTP request object (not used, but required for NGINX configuration) + * @returns {string} current session token or empty string + */ +function sessionToken(r) { + const credentials = readCredentials(r); + if (credentials.sessionToken) { + return credentials.sessionToken; + } + return ''; +} + +/** + * Get the instance profile credentials needed to authenticated against S3 from + * a backend cache. If the credentials cannot be found, then return undefined. + * @param r {Request} HTTP request object (not used, but required for NGINX configuration) + * @returns {undefined|{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string|null), expiration: (string|null)}} AWS instance profile credentials or undefined + */ +function readCredentials(r) { + // TODO: Change the generic constants naming for multiple AWS services. + if ('S3_ACCESS_KEY_ID' in process.env && 'S3_SECRET_KEY' in process.env) { + const sessionToken = 'S3_SESSION_TOKEN' in process.env ? + process.env['S3_SESSION_TOKEN'] : null; + return { + accessKeyId: process.env['S3_ACCESS_KEY_ID'], + secretAccessKey: process.env['S3_SECRET_KEY'], + sessionToken: sessionToken, + expiration: null + }; + } + + if ("variables" in r && r.variables.cache_instance_credentials_enabled == 1) { + return _readCredentialsFromKeyValStore(r); + } else { + return _readCredentialsFromFile(); + } +} + +/** + * Read credentials from the NGINX Keyval store. If it is not found, then + * return undefined. + * + * @param r {Request} HTTP request object (not used, but required for NGINX configuration) + * @returns {undefined|{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials or undefined + * @private + */ +function _readCredentialsFromKeyValStore(r) { + const cached = r.variables.instance_credential_json; + + if (!cached) { + return undefined; + } + + try { + return JSON.parse(cached); + } catch (e) { + utils.debug_log(r, `Error parsing JSON value from r.variables.instance_credential_json: ${e}`); + return undefined; + } +} + +/** + * Read the contents of the credentials file into memory. If it is not + * found, then return undefined. + * + * @returns {undefined|{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials or undefined + * @private + */ +function _readCredentialsFromFile() { + const credsFilePath = _credentialsTempFile(); + + try { + const creds = fs.readFileSync(credsFilePath); + return JSON.parse(creds); + } catch (e) { + /* Do not throw an exception in the case of when the + credentials file path is invalid in order to signal to + the caller that such a file has not been created yet. */ + if (e.code === 'ENOENT') { + return undefined; + } + throw e; + } +} + +/** + * Returns the path to the credentials temporary cache file. + * + * @returns {string} path on the file system to credentials cache file + * @private + */ +function _credentialsTempFile() { + if (process.env['S3_CREDENTIALS_TEMP_FILE']) { + return process.env['S3_CREDENTIALS_TEMP_FILE']; + } + if (process.env['TMPDIR']) { + return `${process.env['TMPDIR']}/credentials.json` + } + + return '/tmp/credentials.json'; +} + +/** + * Write the instance profile credentials to a caching backend. + * + * @param r {Request} HTTP request object (not used, but required for NGINX configuration) + * @param credentials {{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials + */ +function writeCredentials(r, credentials) { + /* Do not bother writing credentials if we are running in a mode where we + do not need instance credentials. */ + if (process.env['S3_ACCESS_KEY_ID'] && process.env['S3_SECRET_KEY']) { + return; + } + + if (!credentials) { + throw `Cannot write invalid credentials: ${JSON.stringify(credentials)}`; + } + + if ("variables" in r && r.variables.cache_instance_credentials_enabled == 1) { + _writeCredentialsToKeyValStore(r, credentials); + } else { + _writeCredentialsToFile(credentials); + } +} + +/** + * Write the instance profile credentials to the NGINX Keyval store. + * + * @param r {Request} HTTP request object (not used, but required for NGINX configuration) + * @param credentials {{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials + * @private + */ +function _writeCredentialsToKeyValStore(r, credentials) { + r.variables.instance_credential_json = JSON.stringify(credentials); +} + +/** + * Write the instance profile credentials to a file on the file system. This + * file will be quite small and should end up in the file cache relatively + * quickly if it is repeatedly read. + * + * @param r {Request} HTTP request object (not used, but required for NGINX configuration) + * @param credentials {{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials + * @private + */ +function _writeCredentialsToFile(credentials) { + fs.writeFileSync(_credentialsTempFile(), JSON.stringify(credentials)); +} + +export default { + readCredentials, + sessionToken, + writeCredentials +} diff --git a/common/etc/nginx/include/s3gateway.js b/common/etc/nginx/include/s3gateway.js index dc883962..bc9b06d1 100644 --- a/common/etc/nginx/include/s3gateway.js +++ b/common/etc/nginx/include/s3gateway.js @@ -14,6 +14,9 @@ * limitations under the License. */ +import awscred from "./awscredentials.js"; +import utils from "./utils.js"; + _require_env_var('S3_BUCKET_NAME'); _require_env_var('S3_SERVER'); _require_env_var('S3_SERVER_PROTO'); @@ -30,14 +33,13 @@ const fs = require('fs'); * about signature generation will be logged. * @type {boolean} */ -const DEBUG = _parseBoolean(process.env['S3_DEBUG']); -const ALLOW_LISTING = _parseBoolean(process.env['ALLOW_DIRECTORY_LIST']); -const PROVIDE_INDEX_PAGE = _parseBoolean(process.env['PROVIDE_INDEX_PAGE']); -const APPEND_SLASH = _parseBoolean(process.env['APPEND_SLASH_FOR_POSSIBLE_DIRECTORY']); -const FOUR_O_FOUR_ON_EMPTY_BUCKET = _parseBoolean(process.env['FOUR_O_FOUR_ON_EMPTY_BUCKET']); +const ALLOW_LISTING = utils.parseBoolean(process.env['ALLOW_DIRECTORY_LIST']); +const PROVIDE_INDEX_PAGE = utils.parseBoolean(process.env['PROVIDE_INDEX_PAGE']); +const APPEND_SLASH = utils.parseBoolean(process.env['APPEND_SLASH_FOR_POSSIBLE_DIRECTORY']); +const FOUR_O_FOUR_ON_EMPTY_BUCKET = utils.parseBoolean(process.env['FOUR_O_FOUR_ON_EMPTY_BUCKET']); const S3_STYLE = process.env['S3_STYLE']; -const ADDITIONAL_HEADER_PREFIXES_TO_STRIP = _parseArray(process.env['HEADER_PREFIXES_TO_STRIP']); +const ADDITIONAL_HEADER_PREFIXES_TO_STRIP = utils.parseArray(process.env['HEADER_PREFIXES_TO_STRIP']); /** * Default filename for index pages to be read off of the backing object store. @@ -164,143 +166,6 @@ function awsHeaderDate(r) { return _amzDatetime(NOW, _eightDigitDate(NOW)); } -/** - * Returns the path to the credentials temporary cache file. - * - * @returns {string} path on the file system to credentials cache file - * @private - */ -function _credentialsTempFile() { - if (process.env['S3_CREDENTIALS_TEMP_FILE']) { - return process.env['S3_CREDENTIALS_TEMP_FILE']; - } - if (process.env['TMPDIR']) { - return `${process.env['TMPDIR']}/credentials.json` - } - - return '/tmp/credentials.json'; -} - -/** - * Write the instance profile credentials to a caching backend. - * - * @param r {Request} HTTP request object (not used, but required for NGINX configuration) - * @param credentials {{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials - */ -function writeCredentials(r, credentials) { - /* Do not bother writing credentials if we are running in a mode where we - do not need instance credentials. */ - if (process.env['S3_ACCESS_KEY_ID'] && process.env['S3_SECRET_KEY']) { - return; - } - - if (!credentials) { - throw `Cannot write invalid credentials: ${JSON.stringify(credentials)}`; - } - - if ("variables" in r && r.variables.cache_instance_credentials_enabled == 1) { - _writeCredentialsToKeyValStore(r, credentials); - } else { - _writeCredentialsToFile(credentials); - } -} - -/** - * Write the instance profile credentials to the NGINX Keyval store. - * - * @param r {Request} HTTP request object (not used, but required for NGINX configuration) - * @param credentials {{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials - * @private - */ -function _writeCredentialsToKeyValStore(r, credentials) { - r.variables.instance_credential_json = JSON.stringify(credentials); -} - -/** - * Write the instance profile credentials to a file on the file system. This - * file will be quite small and should end up in the file cache relatively - * quickly if it is repeatedly read. - * - * @param r {Request} HTTP request object (not used, but required for NGINX configuration) - * @param credentials {{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials - * @private - */ -function _writeCredentialsToFile(credentials) { - fs.writeFileSync(_credentialsTempFile(), JSON.stringify(credentials)); -} - -/** - * Get the instance profile credentials needed to authenticated against S3 from - * a backend cache. If the credentials cannot be found, then return undefined. - * @param r {Request} HTTP request object (not used, but required for NGINX configuration) - * @returns {undefined|{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string|null), expiration: (string|null)}} AWS instance profile credentials or undefined - */ -function readCredentials(r) { - if ('S3_ACCESS_KEY_ID' in process.env && 'S3_SECRET_KEY' in process.env) { - const sessionToken = 'S3_SESSION_TOKEN' in process.env ? - process.env['S3_SESSION_TOKEN'] : null; - return { - accessKeyId: process.env['S3_ACCESS_KEY_ID'], - secretAccessKey: process.env['S3_SECRET_KEY'], - sessionToken: sessionToken, - expiration: null - }; - } - - if ("variables" in r && r.variables.cache_instance_credentials_enabled == 1) { - return _readCredentialsFromKeyValStore(r); - } else { - return _readCredentialsFromFile(); - } -} - -/** - * Read credentials from the NGINX Keyval store. If it is not found, then - * return undefined. - * - * @param r {Request} HTTP request object (not used, but required for NGINX configuration) - * @returns {undefined|{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials or undefined - * @private - */ -function _readCredentialsFromKeyValStore(r) { - const cached = r.variables.instance_credential_json; - - if (!cached) { - return undefined; - } - - try { - return JSON.parse(cached); - } catch (e) { - _debug_log(r, `Error parsing JSON value from r.variables.instance_credential_json: ${e}`); - return undefined; - } -} - -/** - * Read the contents of the credentials file into memory. If it is not - * found, then return undefined. - * - * @returns {undefined|{accessKeyId: (string), secretAccessKey: (string), sessionToken: (string), expiration: (string)}} AWS instance profile credentials or undefined - * @private - */ -function _readCredentialsFromFile() { - const credsFilePath = _credentialsTempFile(); - - try { - const creds = fs.readFileSync(credsFilePath); - return JSON.parse(creds); - } catch (e) { - /* Do not throw an exception in the case of when the - credentials file path is invalid in order to signal to - the caller that such a file has not been created yet. */ - if (e.code === 'ENOENT') { - return undefined; - } - throw e; - } -} - /** * Creates an AWS authentication signature based on the global settings and * the passed request parameter. @@ -321,7 +186,7 @@ function s3auth(r) { let signature; - const credentials = readCredentials(r); + const credentials = awscred.readCredentials(r); if (sigver == '2') { signature = signatureV2(r, bucket, credentials); } else { @@ -331,20 +196,6 @@ function s3auth(r) { return signature; } -/** - * Get the current session token from the instance profile credential cache. - * - * @param r {Request} HTTP request object (not used, but required for NGINX configuration) - * @returns {string} current session token or empty string - */ -function s3SecurityToken(r) { - const credentials = readCredentials(r); - if (credentials.sessionToken) { - return credentials.sessionToken; - } - return ''; -} - /** * Build the base file path for a S3 request URI. This function allows for * path style S3 URIs to be created that do not use a subdomain to specify @@ -358,7 +209,7 @@ function s3BaseUri(r) { let basePath; if (S3_STYLE === 'path') { - _debug_log(r, 'Using path style uri : ' + '/' + bucket); + utils.debug_log(r, 'Using path style uri : ' + '/' + bucket); basePath = '/' + bucket; } else { basePath = ''; @@ -394,7 +245,7 @@ function s3uri(r) { path = _escapeURIPath(basePath + uriPath); } - _debug_log(r, 'S3 Request URI: ' + r.method + ' ' + path); + utils.debug_log(r, 'S3 Request URI: ' + r.method + ' ' + path); return path; } @@ -440,7 +291,7 @@ function _s3DirQueryParams(uriPath, method) { function redirectToS3(r) { // This is a read-only S3 gateway, so we do not support any other methods if (!(r.method === 'GET' || r.method === 'HEAD')) { - _debug_log(r, 'Invalid method requested: ' + r.method); + utils.debug_log(r, 'Invalid method requested: ' + r.method); r.internalRedirect("@error405"); return; } @@ -495,7 +346,7 @@ function signatureV2(r, bucket, credentials) { const httpDate = s3date(r); const stringToSign = method + '\n\n\n' + httpDate + '\n' + '/' + bucket + uri; - _debug_log(r, 'AWS v2 Auth Signing String: [' + stringToSign + ']'); + utils.debug_log(r, 'AWS v2 Auth Signing String: [' + stringToSign + ']'); const s3signature = hmac.update(stringToSign).digest('base64'); @@ -516,7 +367,7 @@ function signatureV2(r, bucket, credentials) { */ function filterListResponse(r, data, flags) { if (FOUR_O_FOUR_ON_EMPTY_BUCKET) { - let indexIsEmpty = _parseBoolean(r.variables.indexIsEmpty); + let indexIsEmpty = utils.parseBoolean(r.variables.indexIsEmpty); if (indexIsEmpty && data.indexOf('= 0) { r.variables.indexIsEmpty = false; @@ -573,7 +424,7 @@ function signatureV4(r, timestamp, bucket, region, server, credentials) { .concat(credentials.accessKeyId, '/', eightDigitDate, '/', region, '/', SERVICE, '/aws4_request,', 'SignedHeaders=', signedHeaders(credentials.sessionToken), ',Signature=', signature); - _debug_log(r, 'AWS v4 Auth header: [' + authHeader + ']'); + utils.debug_log(r, 'AWS v4 Auth header: [' + authHeader + ']'); return authHeader; } @@ -611,17 +462,17 @@ function _buildSignatureV4(r, amzDatetime, eightDigitDate, creds, bucket, region const canonicalRequest = _buildCanonicalRequest(method, uri, queryParams, host, amzDatetime, creds.sessionToken); - _debug_log(r, 'AWS v4 Auth Canonical Request: [' + canonicalRequest + ']'); + utils.debug_log(r, 'AWS v4 Auth Canonical Request: [' + canonicalRequest + ']'); const canonicalRequestHash = mod_hmac.createHash('sha256') .update(canonicalRequest) .digest('hex'); - _debug_log(r, 'AWS v4 Auth Canonical Request Hash: [' + canonicalRequestHash + ']'); + utils.debug_log(r, 'AWS v4 Auth Canonical Request Hash: [' + canonicalRequestHash + ']'); const stringToSign = _buildStringToSign(amzDatetime, eightDigitDate, region, canonicalRequestHash); - _debug_log(r, 'AWS v4 Auth Signing String: [' + stringToSign + ']'); + utils.debug_log(r, 'AWS v4 Auth Signing String: [' + stringToSign + ']'); let kSigningHash; @@ -640,7 +491,7 @@ function _buildSignatureV4(r, amzDatetime, eightDigitDate, creds, bucket, region // If true, use cached value if (cacheIsValid) { - _debug_log(r, 'AWS v4 Using cached Signing Key Hash'); + utils.debug_log(r, 'AWS v4 Using cached Signing Key Hash'); /* We are forced to JSON encode the string returned from the HMAC * operation because it is in a very specific format that include * binary data and in order to preserve that data when persisting @@ -650,7 +501,7 @@ function _buildSignatureV4(r, amzDatetime, eightDigitDate, creds, bucket, region // Otherwise, generate a new signing key hash and store it in the cache } else { kSigningHash = _buildSigningKeyHash(creds.secretAccessKey, eightDigitDate, SERVICE, region); - _debug_log(r, 'Writing key: ' + eightDigitDate + ':' + kSigningHash.toString('hex')); + utils.debug_log(r, 'Writing key: ' + eightDigitDate + ':' + kSigningHash.toString('hex')); r.variables.signing_key_hash = eightDigitDate + ':' + JSON.stringify(kSigningHash); } // Otherwise, don't use caching at all (like when we are using NGINX OSS) @@ -658,12 +509,12 @@ function _buildSignatureV4(r, amzDatetime, eightDigitDate, creds, bucket, region kSigningHash = _buildSigningKeyHash(creds.secretAccessKey, eightDigitDate, SERVICE, region); } - _debug_log(r, 'AWS v4 Signing Key Hash: [' + kSigningHash.toString('hex') + ']'); + utils.debug_log(r, 'AWS v4 Signing Key Hash: [' + kSigningHash.toString('hex') + ']'); const signature = mod_hmac.createHmac('sha256', kSigningHash) .update(stringToSign).digest('hex'); - _debug_log(r, 'AWS v4 Authorization Header: [' + signature + ']'); + utils.debug_log(r, 'AWS v4 Authorization Header: [' + signature + ']'); return signature; } @@ -873,61 +724,6 @@ function _isDirectory(path) { return path.charAt(len - 1) === '/'; } -/** - * Parses a string to and returns a boolean value based on its value. If the - * string can't be parsed, this method returns false. - * - * @param string {*} value representing a boolean - * @returns {boolean} boolean value of string - * @private - */ -function _parseBoolean(string) { - switch(string) { - case "TRUE": - case "true": - case "True": - case "YES": - case "yes": - case "Yes": - case "1": - return true; - default: - return false; - } -} - -/** - * Parses a string delimited by semicolons into an array of values - * @param string {string|null} value representing a array of strings - * @returns {Array} a list of values - * @private - */ -function _parseArray(string) { - if (string == null || !string || string === ';') { - return []; - } - - // Exclude trailing delimiter - if (string.endsWith(';')) { - return string.substr(0, string.length - 1).split(';'); - } - - return string.split(';') -} - -/** - * Outputs a log message to the request logger if debug messages are enabled. - * - * @param r {Request} HTTP request object - * @param msg {string} message to log - * @private - */ -function _debug_log(r, msg) { - if (DEBUG && "log" in r) { - r.log(msg); - } -} - /** * Checks to see if the given environment variable is present. If not, an error * is thrown. @@ -982,9 +778,9 @@ async function fetchCredentials(r) { let current; try { - current = readCredentials(r); + current = awscred.readCredentials(r); } catch (e) { - _debug_log(r, `Could not read credentials: ${e}`); + utils.debug_log(r, `Could not read credentials: ${e}`); r.return(500); return; } @@ -1002,14 +798,14 @@ async function fetchCredentials(r) { let credentials; - _debug_log(r, 'Cached credentials are expired or not present, requesting new ones'); + utils.debug_log(r, 'Cached credentials are expired or not present, requesting new ones'); if (process.env['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI']) { const uri = ECS_CREDENTIAL_BASE_URI + process.env['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI']; try { credentials = await _fetchEcsRoleCredentials(uri); } catch (e) { - _debug_log(r, 'Could not load ECS task role credentials: ' + JSON.stringify(e)); + utils.debug_log(r, 'Could not load ECS task role credentials: ' + JSON.stringify(e)); r.return(500); return; } @@ -1018,7 +814,7 @@ async function fetchCredentials(r) { try { credentials = await _fetchWebIdentityCredentials(r) } catch(e) { - _debug_log(r, 'Could not assume role using web identity: ' + JSON.stringify(e)); + utils.debug_log(r, 'Could not assume role using web identity: ' + JSON.stringify(e)); r.return(500); return; } @@ -1026,15 +822,15 @@ async function fetchCredentials(r) { try { credentials = await _fetchEC2RoleCredentials(); } catch (e) { - _debug_log(r, 'Could not load EC2 task role credentials: ' + JSON.stringify(e)); + utils.debug_log(r, 'Could not load EC2 task role credentials: ' + JSON.stringify(e)); r.return(500); return; } } try { - writeCredentials(r, credentials); + awscred.writeCredentials(r, credentials); } catch (e) { - _debug_log(r, `Could not write credentials: ${e}`); + utils.debug_log(r, `Could not write credentials: ${e}`); r.return(500); return; } @@ -1169,11 +965,8 @@ async function _fetchWebIdentityCredentials(r) { export default { awsHeaderDate, fetchCredentials, - readCredentials, - writeCredentials, s3date, s3auth, - s3SecurityToken, s3uri, trailslashControl, redirectToS3, @@ -1189,6 +982,5 @@ export default { _buildSigningKeyHash, _buildSignatureV4, _escapeURIPath, - _parseArray, _isHeaderToBeStripped }; diff --git a/common/etc/nginx/include/utils.js b/common/etc/nginx/include/utils.js new file mode 100644 index 00000000..c69d8a37 --- /dev/null +++ b/common/etc/nginx/include/utils.js @@ -0,0 +1,81 @@ +/* + * Copyright 2023 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Flag indicating debug mode operation. If true, additional information + * about signature generation will be logged. + * @type {boolean} + */ +const DEBUG = parseBoolean(process.env['S3_DEBUG']); + + +/** + * Parses a string delimited by semicolons into an array of values + * @param string {string|null} value representing a array of strings + * @returns {Array} a list of values + */ +function parseArray(string) { + if (string == null || !string || string === ';') { + return []; + } + + // Exclude trailing delimiter + if (string.endsWith(';')) { + return string.substr(0, string.length - 1).split(';'); + } + + return string.split(';') +} + +/** + * Parses a string to and returns a boolean value based on its value. If the + * string can't be parsed, this method returns false. + * + * @param string {*} value representing a boolean + * @returns {boolean} boolean value of string + */ +function parseBoolean(string) { + switch(string) { + case "TRUE": + case "true": + case "True": + case "YES": + case "yes": + case "Yes": + case "1": + return true; + default: + return false; + } +} + +/** + * Outputs a log message to the request logger if debug messages are enabled. + * + * @param r {Request} HTTP request object + * @param msg {string} message to log + */ +function debug_log(r, msg) { + if (DEBUG && "log" in r) { + r.log(msg); + } +} + +export default { + debug_log, + parseArray, + parseBoolean +} diff --git a/common/etc/nginx/templates/default.conf.template b/common/etc/nginx/templates/default.conf.template index 24fae2bb..cf4cd605 100644 --- a/common/etc/nginx/templates/default.conf.template +++ b/common/etc/nginx/templates/default.conf.template @@ -1,3 +1,4 @@ +js_import /etc/nginx/include/awscredentials.js; js_import /etc/nginx/include/s3gateway.js; # We include only the variables needed for the authentication signatures that @@ -20,7 +21,7 @@ map $S3_STYLE $s3_host_hdr { js_var $indexIsEmpty true; # This creates the HTTP authentication header to be sent to S3 js_set $s3auth s3gateway.s3auth; -js_set $s3SecurityToken s3gateway.s3SecurityToken; +js_set $awsSessionToken awscredentials.sessionToken; js_set $s3uri s3gateway.s3uri; server { @@ -111,7 +112,7 @@ server { # Set the Authorization header to the AWS Signatures credentials proxy_set_header Authorization $s3auth; - proxy_set_header X-Amz-Security-Token $s3SecurityToken; + proxy_set_header X-Amz-Security-Token $awsSessionToken; # We set the host as the bucket name to inform the S3 API of the bucket proxy_set_header Host $s3_host_hdr; @@ -158,7 +159,7 @@ server { # Set the Authorization header to the AWS Signatures credentials proxy_set_header Authorization $s3auth; - proxy_set_header X-Amz-Security-Token $s3SecurityToken; + proxy_set_header X-Amz-Security-Token $awsSessionToken; # We set the host as the bucket name to inform the S3 API of the bucket proxy_set_header Host $s3_host_hdr; diff --git a/standalone_ubuntu_oss_install.sh b/standalone_ubuntu_oss_install.sh index 7e971e26..277a72ba 100644 --- a/standalone_ubuntu_oss_install.sh +++ b/standalone_ubuntu_oss_install.sh @@ -300,6 +300,7 @@ EOF cat >> "/etc/nginx/environment" << EOF env S3_SESSION_TOKEN; EOF + fi fi cat >> /etc/nginx/nginx.conf << 'EOF' @@ -348,7 +349,9 @@ http { EOF download "common/etc/nginx/include/listing.xsl" "/etc/nginx/include/listing.xsl" +download "common/etc/nginx/include/awscredentials.js" "/etc/nginx/include/awscredentials.js" download "common/etc/nginx/include/s3gateway.js" "/etc/nginx/include/s3gateway.js" +download "common/etc/nginx/include/utils.js" "/etc/nginx/include/utils.js" download "common/etc/nginx/templates/default.conf.template" "/etc/nginx/templates/default.conf.template" download "common/etc/nginx/templates/gateway/v2_headers.conf.template" "/etc/nginx/templates/gateway/v2_headers.conf.template" download "common/etc/nginx/templates/gateway/v2_js_vars.conf.template" "/etc/nginx/templates/gateway/v2_js_vars.conf.template" diff --git a/test.sh b/test.sh index 999408b4..c0b3b029 100755 --- a/test.sh +++ b/test.sh @@ -265,47 +265,63 @@ fi ### UNIT TESTS +runUnitTestWithOutSessionToken() { + test_code="$1" + + #MSYS_NO_PATHCONV=1 added to resolve automatic path conversion + # https://github.com/docker/for-win/issues/6754#issuecomment-629702199 + MSYS_NO_PATHCONV=1 "${docker_cmd}" run \ + --rm \ + -v "$(pwd)/test/unit:/var/tmp" \ + --workdir /var/tmp \ + -e "S3_DEBUG=true" \ + -e "S3_STYLE=virtual" \ + -e "S3_ACCESS_KEY_ID=unit_test" \ + -e "S3_SECRET_KEY=unit_test" \ + -e "S3_BUCKET_NAME=unit_test" \ + -e "S3_SERVER=unit_test" \ + -e "S3_SERVER_PROTO=https" \ + -e "S3_SERVER_PORT=443" \ + -e "S3_REGION=test-1" \ + -e "AWS_SIGS_VERSION=4" \ + --entrypoint /usr/bin/njs \ + nginx-s3-gateway -t module -p '/etc/nginx' /var/tmp/"${test_code}" +} + +runUnitTestWithSessionToken() { + test_code="$1" + + #MSYS_NO_PATHCONV=1 added to resolve automatic path conversion + # https://github.com/docker/for-win/issues/6754#issuecomment-629702199 + MSYS_NO_PATHCONV=1 "${docker_cmd}" run \ + --rm \ + -v "$(pwd)/test/unit:/var/tmp" \ + --workdir /var/tmp \ + -e "S3_DEBUG=true" \ + -e "S3_STYLE=virtual" \ + -e "S3_ACCESS_KEY_ID=unit_test" \ + -e "S3_SECRET_KEY=unit_test" \ + -e "S3_SESSION_TOKEN=unit_test" \ + -e "S3_BUCKET_NAME=unit_test" \ + -e "S3_SERVER=unit_test" \ + -e "S3_SERVER_PROTO=https" \ + -e "S3_SERVER_PORT=443" \ + -e "S3_REGION=test-1" \ + -e "AWS_SIGS_VERSION=4" \ + --entrypoint /usr/bin/njs \ + nginx-s3-gateway -t module -p '/etc/nginx' /var/tmp/"${test_code}" +} + +p "Running unit tests for utils" +runUnitTestWithSessionToken "utils_test.js" + p "Running unit tests with an access key ID and a secret key in Docker image" -#MSYS_NO_PATHCONV=1 added to resolve automatic path conversion -# https://github.com/docker/for-win/issues/6754#issuecomment-629702199 -MSYS_NO_PATHCONV=1 "${docker_cmd}" run \ - --rm \ - -v "$(pwd)/test/unit:/var/tmp" \ - --workdir /var/tmp \ - -e "S3_DEBUG=true" \ - -e "S3_STYLE=virtual" \ - -e "S3_ACCESS_KEY_ID=unit_test" \ - -e "S3_SECRET_KEY=unit_test" \ - -e "S3_SESSION_TOKEN=unit_test" \ - -e "S3_BUCKET_NAME=unit_test" \ - -e "S3_SERVER=unit_test" \ - -e "S3_SERVER_PROTO=https" \ - -e "S3_SERVER_PORT=443" \ - -e "S3_REGION=test-1" \ - -e "AWS_SIGS_VERSION=4" \ - --entrypoint /usr/bin/njs \ - nginx-s3-gateway -t module -p '/etc/nginx' /var/tmp/s3gateway_test.js - -p "Running unit tests with a session token in Docker image" -#MSYS_NO_PATHCONV=1 added to resolve automatic path conversion -# https://github.com/docker/for-win/issues/6754#issuecomment-629702199 -MSYS_NO_PATHCONV=1 "${docker_cmd}" run \ - --rm \ - -v "$(pwd)/test/unit:/var/tmp" \ - --workdir /var/tmp \ - -e "S3_DEBUG=true" \ - -e "S3_STYLE=virtual" \ - -e "S3_ACCESS_KEY_ID=unit_test" \ - -e "S3_SECRET_KEY=unit_test" \ - -e "S3_BUCKET_NAME=unit_test" \ - -e "S3_SERVER=unit_test" \ - -e "S3_SERVER_PROTO=https" \ - -e "S3_SERVER_PORT=443" \ - -e "S3_REGION=test-1" \ - -e "AWS_SIGS_VERSION=4" \ - --entrypoint /usr/bin/njs \ - nginx-s3-gateway -t module -p '/etc/nginx' /var/tmp/s3gateway_test.js +runUnitTestWithOutSessionToken "awscredentials_test.js" +runUnitTestWithOutSessionToken "s3gateway_test.js" +p "Running unit tests with an session token in Docker image" +runUnitTestWithSessionToken "awscredentials_test.js" +runUnitTestWithSessionToken "s3gateway_test.js" ### INTEGRATION TESTS diff --git a/test/unit/awscredentials_test.js b/test/unit/awscredentials_test.js new file mode 100644 index 00000000..9a5a6d16 --- /dev/null +++ b/test/unit/awscredentials_test.js @@ -0,0 +1,191 @@ +#!env njs + +/* + * Copyright 2023 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import awscred from "include/awscredentials.js"; +import fs from "fs"; + + +function testReadCredentialsWithAccessSecretKeyAndSessionTokenSet() { + printHeader('testReadCredentialsWithAccessSecretKeyAndSessionTokenSet'); + let r = {}; + process.env['S3_ACCESS_KEY_ID'] = 'SOME_ACCESS_KEY'; + process.env['S3_SECRET_KEY'] = 'SOME_SECRET_KEY'; + if ('S3_SESSION_TOKEN' in process.env) { + process.env['S3_SESSION_TOKEN'] = 'SOME_SESSION_TOKEN'; + } + + try { + var credentials = awscred.readCredentials(r); + if (credentials.accessKeyId !== process.env['S3_ACCESS_KEY_ID']) { + throw 'static credentials do not match returned value [accessKeyId]'; + } + if (credentials.secretAccessKey !== process.env['S3_SECRET_KEY']) { + throw 'static credentials do not match returned value [secretAccessKey]'; + } + if ('S3_SESSION_TOKEN' in process.env) { + if (credentials.sessionToken !== process.env['S3_SESSION_TOKEN']) { + throw 'static credentials do not match returned value [sessionToken]'; + } + } else { + if (credentials.sessionToken !== null) { + throw 'static credentials do not match returned value [sessionToken]'; + } + } + if (credentials.expiration !== null) { + throw 'static credentials do not match returned value [expiration]'; + } + + } finally { + delete process.env.S3_ACCESS_KEY_ID; + delete process.env.S3_SECRET_KEY; + if ('S3_SESSION_TOKEN' in process.env) { + delete process.env.S3_SESSION_TOKEN; + } + } +} + +function testReadCredentialsFromFilePath() { + printHeader('testReadCredentialsFromFilePath'); + let r = { + variables: { + cache_instance_credentials_enabled: 0 + } + }; + + var originalCredentialPath = process.env['S3_CREDENTIALS_TEMP_FILE']; + var tempDir = (process.env['TMPDIR'] ? process.env['TMPDIR'] : '/tmp'); + var uniqId = `${new Date().getTime()}-${Math.floor(Math.random()*101)}`; + var tempFile = `${tempDir}/credentials-unit-test-${uniqId}.json`; + var testData = '{"accessKeyId":"A","secretAccessKey":"B",' + + '"sessionToken":"C","expiration":"2022-02-15T04:49:08Z"}'; + fs.writeFileSync(tempFile, testData); + + try { + process.env['S3_CREDENTIALS_TEMP_FILE'] = tempFile; + var credentials = awscred.readCredentials(r); + var testDataAsJSON = JSON.parse(testData); + if (credentials.accessKeyId !== testDataAsJSON.accessKeyId) { + throw 'JSON test data does not match credentials [accessKeyId]'; + } + if (credentials.secretAccessKey !== testDataAsJSON.secretAccessKey) { + throw 'JSON test data does not match credentials [secretAccessKey]'; + } + if (credentials.sessionToken !== testDataAsJSON.sessionToken) { + throw 'JSON test data does not match credentials [sessionToken]'; + } + if (credentials.expiration !== testDataAsJSON.expiration) { + throw 'JSON test data does not match credentials [expiration]'; + } + } finally { + if (originalCredentialPath) { + process.env['S3_CREDENTIALS_TEMP_FILE'] = originalCredentialPath; + } + if (fs.statSync(tempFile, {throwIfNoEntry: false})) { + fs.unlinkSync(tempFile); + } + } +} + +function testReadCredentialsFromNonexistentPath() { + printHeader('testReadCredentialsFromNonexistentPath'); + let r = { + variables: { + cache_instance_credentials_enabled: 0 + } + }; + var originalCredentialPath = process.env['S3_CREDENTIALS_TEMP_FILE']; + var tempDir = (process.env['TMPDIR'] ? process.env['TMPDIR'] : '/tmp'); + var uniqId = `${new Date().getTime()}-${Math.floor(Math.random()*101)}`; + var tempFile = `${tempDir}/credentials-unit-test-${uniqId}.json`; + + try { + process.env['S3_CREDENTIALS_TEMP_FILE'] = tempFile; + var credentials = awscred.readCredentials(r); + if (credentials !== undefined) { + throw 'Credentials returned when no credentials file should be present'; + } + + } finally { + if (originalCredentialPath) { + process.env['S3_CREDENTIALS_TEMP_FILE'] = originalCredentialPath; + } + if (fs.statSync(tempFile, {throwIfNoEntry: false})) { + fs.unlinkSync(tempFile); + } + } +} + +function testReadAndWriteCredentialsFromKeyValStore() { + printHeader('testReadAndWriteCredentialsFromKeyValStore'); + + let accessKeyId = process.env['S3_ACCESS_KEY_ID']; + let secretKey = process.env['S3_SECRET_KEY']; + let sessionToken = null; + if ('S3_SESSION_TOKEN' in process.env) { + sessionToken = process.env['S3_SESSION_TOKEN']; + } + delete process.env.S3_ACCESS_KEY_ID; + delete process.env.S3_SECRET_KEY; + if ('S3_SESSION_TOKEN' in process.env) { + delete process.env.S3_SESSION_TOKEN + } + try { + let r = { + variables: { + cache_instance_credentials_enabled: 1, + instance_credential_json: null + } + }; + let expectedCredentials = { + AccessKeyId: 'AN_ACCESS_KEY_ID', + Expiration: '2017-05-17T15:09:54Z', + RoleArn: 'TASK_ROLE_ARN', + SecretAccessKey: 'A_SECRET_ACCESS_KEY', + Token: 'A_SECURITY_TOKEN', + }; + + awscred.writeCredentials(r, expectedCredentials); + let credentials = JSON.stringify(awscred.readCredentials(r)); + let expectedJson = JSON.stringify(expectedCredentials); + + if (credentials !== expectedJson) { + console.log(`EXPECTED:\n${expectedJson}\nACTUAL:\n${credentials}`); + throw 'Credentials do not match expected value'; + } + } finally { + process.env['S3_ACCESS_KEY_ID'] = accessKeyId; + process.env['S3_SECRET_KEY'] = secretKey; + if ('S3_SESSION_TOKEN' in process.env) { + process.env['S3_SESSION_TOKEN'] = sessionToken + } + } +} + +async function test() { + testReadCredentialsWithAccessSecretKeyAndSessionTokenSet(); + testReadCredentialsFromFilePath(); + testReadCredentialsFromNonexistentPath(); + testReadAndWriteCredentialsFromKeyValStore(); +} + +function printHeader(testName) { + console.log(`\n## ${testName}`); +} + +test(); +console.log('Finished unit tests for awscredentials.js'); diff --git a/test/unit/s3gateway_test.js b/test/unit/s3gateway_test.js index f810c798..8d1db911 100755 --- a/test/unit/s3gateway_test.js +++ b/test/unit/s3gateway_test.js @@ -17,7 +17,6 @@ */ import s3gateway from "include/s3gateway.js"; -import fs from "fs"; globalThis.ngx = {}; @@ -368,154 +367,6 @@ function testEscapeURIPathPreservesDoubleSlashes() { } } -function testReadCredentialsWithAccessSecretKeyAndSessionTokenSet() { - printHeader('testReadCredentialsWithAccessSecretKeyAndSessionTokenSet'); - let r = {}; - process.env['S3_ACCESS_KEY_ID'] = 'SOME_ACCESS_KEY'; - process.env['S3_SECRET_KEY'] = 'SOME_SECRET_KEY'; - if ('S3_SESSION_TOKEN' in process.env) { - process.env['S3_SESSION_TOKEN'] = 'SOME_SESSION_TOKEN'; - } - - try { - var credentials = s3gateway.readCredentials(r); - if (credentials.accessKeyId !== process.env['S3_ACCESS_KEY_ID']) { - throw 'static credentials do not match returned value [accessKeyId]'; - } - if (credentials.secretAccessKey !== process.env['S3_SECRET_KEY']) { - throw 'static credentials do not match returned value [secretAccessKey]'; - } - if ('S3_SESSION_TOKEN' in process.env) { - if (credentials.sessionToken !== process.env['S3_SESSION_TOKEN']) { - throw 'static credentials do not match returned value [sessionToken]'; - } - } else { - if (credentials.sessionToken !== null) { - throw 'static credentials do not match returned value [sessionToken]'; - } - } - if (credentials.expiration !== null) { - throw 'static credentials do not match returned value [expiration]'; - } - - } finally { - delete process.env.S3_ACCESS_KEY_ID; - delete process.env.S3_SECRET_KEY; - delete process.env.S3_SESSION_TOKEN; - } -} - -function testReadCredentialsFromFilePath() { - printHeader('testReadCredentialsFromFilePath'); - let r = { - variables: { - cache_instance_credentials_enabled: 0 - } - }; - - var originalCredentialPath = process.env['S3_CREDENTIALS_TEMP_FILE']; - var tempDir = (process.env['TMPDIR'] ? process.env['TMPDIR'] : '/tmp'); - var uniqId = `${new Date().getTime()}-${Math.floor(Math.random()*101)}`; - var tempFile = `${tempDir}/credentials-unit-test-${uniqId}.json`; - var testData = '{"accessKeyId":"A","secretAccessKey":"B",' + - '"sessionToken":"C","expiration":"2022-02-15T04:49:08Z"}'; - fs.writeFileSync(tempFile, testData); - - try { - process.env['S3_CREDENTIALS_TEMP_FILE'] = tempFile; - var credentials = s3gateway.readCredentials(r); - var testDataAsJSON = JSON.parse(testData); - if (credentials.accessKeyId !== testDataAsJSON.accessKeyId) { - throw 'JSON test data does not match credentials [accessKeyId]'; - } - if (credentials.secretAccessKey !== testDataAsJSON.secretAccessKey) { - throw 'JSON test data does not match credentials [secretAccessKey]'; - } - if (credentials.sessionToken !== testDataAsJSON.sessionToken) { - throw 'JSON test data does not match credentials [sessionToken]'; - } - if (credentials.expiration !== testDataAsJSON.expiration) { - throw 'JSON test data does not match credentials [expiration]'; - } - } finally { - if (originalCredentialPath) { - process.env['S3_CREDENTIALS_TEMP_FILE'] = originalCredentialPath; - } - if (fs.statSync(tempFile, {throwIfNoEntry: false})) { - fs.unlinkSync(tempFile); - } - } -} - -function testReadCredentialsFromNonexistentPath() { - printHeader('testReadCredentialsFromNonexistentPath'); - let r = { - variables: { - cache_instance_credentials_enabled: 0 - } - }; - var originalCredentialPath = process.env['S3_CREDENTIALS_TEMP_FILE']; - var tempDir = (process.env['TMPDIR'] ? process.env['TMPDIR'] : '/tmp'); - var uniqId = `${new Date().getTime()}-${Math.floor(Math.random()*101)}`; - var tempFile = `${tempDir}/credentials-unit-test-${uniqId}.json`; - - try { - process.env['S3_CREDENTIALS_TEMP_FILE'] = tempFile; - var credentials = s3gateway.readCredentials(r); - if (credentials !== undefined) { - throw 'Credentials returned when no credentials file should be present'; - } - - } finally { - if (originalCredentialPath) { - process.env['S3_CREDENTIALS_TEMP_FILE'] = originalCredentialPath; - } - if (fs.statSync(tempFile, {throwIfNoEntry: false})) { - fs.unlinkSync(tempFile); - } - } -} - -function testReadAndWriteCredentialsFromKeyValStore() { - printHeader('testReadAndWriteCredentialsFromKeyValStore'); - - let accessKeyId = process.env['S3_ACCESS_KEY_ID']; - let secretKey = process.env['S3_SECRET_KEY']; - let sessionToken = process.env['S3_SESSION_TOKEN']; - delete process.env.S3_ACCESS_KEY_ID; - delete process.env.S3_SECRET_KEY; - delete process.env.S3_SESSION_TOKEN - - try { - let r = { - variables: { - cache_instance_credentials_enabled: 1, - instance_credential_json: null - } - }; - let expectedCredentials = { - AccessKeyId: 'AN_ACCESS_KEY_ID', - Expiration: '2017-05-17T15:09:54Z', - RoleArn: 'TASK_ROLE_ARN', - SecretAccessKey: 'A_SECRET_ACCESS_KEY', - Token: 'A_SECURITY_TOKEN', - }; - - s3gateway.writeCredentials(r, expectedCredentials); - let credentials = JSON.stringify(s3gateway.readCredentials(r)); - let expectedJson = JSON.stringify(expectedCredentials); - - if (credentials !== expectedJson) { - console.log(`EXPECTED:\n${expectedJson}\nACTUAL:\n${credentials}`); - throw 'Credentials do not match expected value'; - } - } finally { - process.env['S3_ACCESS_KEY_ID'] = accessKeyId; - process.env['S3_SECRET_KEY'] = secretKey; - process.env['S3_SESSION_TOKEN'] = sessionToken; - } -} - async function testEcsCredentialRetrieval() { printHeader('testEcsCredentialRetrieval'); process.env['S3_ACCESS_KEY_ID'] = undefined; @@ -639,71 +490,6 @@ function printHeader(testName) { console.log(`\n## ${testName}`); } -function testParseArray() { - printHeader('testParseArray'); - - function testParseNull() { - console.log(' ## testParseNull'); - const actual = s3gateway._parseArray(null); - if (!Array.isArray(actual) || actual.length > 0) { - throw 'Null not parsed into an empty array'; - } - } - function testParseEmptyString() { - console.log(' ## testParseEmptyString'); - const actual = s3gateway._parseArray(''); - if (!Array.isArray(actual) || actual.length > 0) { - throw 'Empty string not parsed into an empty array'; - } - } - function testParseSingleValue() { - console.log(' ## testParseSingleValue'); - const value = 'Single Value'; - const actual = s3gateway._parseArray(value); - if (!Array.isArray(actual) || actual.length !== 1) { - throw 'Single value not parsed into an array with a single element'; - } - if (actual[0] !== value) { - throw `Unexpected array element: ${actual[0]}` - } - } - function testParseMultipleValues() { - console.log(' ## testParseMultipleValues'); - const values = ['string 1', 'something else', 'Yet another value']; - const textValues = values.join(';'); - const actual = s3gateway._parseArray(textValues); - if (!Array.isArray(actual) || actual.length !== values.length) { - throw 'Multiple values not parsed into an array with the expected length'; - } - for (let i = 0; i < values.length; i++) { - if (values[i] !== actual[i]) { - throw `Unexpected array element [${i}]: ${actual[i]}` - } - } - } - - function testParseMultipleValuesTrailingDelimiter() { - console.log(' ## testParseMultipleValuesTrailingDelimiter'); - const values = ['string 1', 'something else', 'Yet another value']; - const textValues = values.join(';'); - const actual = s3gateway._parseArray(textValues + ';'); - if (!Array.isArray(actual) || actual.length !== values.length) { - throw 'Multiple values not parsed into an array with the expected length'; - } - for (let i = 0; i < values.length; i++) { - if (values[i] !== actual[i]) { - throw `Unexpected array element [${i}]: ${actual[i]}` - } - } - } - - testParseNull(); - testParseEmptyString(); - testParseSingleValue(); - testParseMultipleValues(); - testParseMultipleValuesTrailingDelimiter(); -} - async function test() { testEncodeURIComponent(); testPad(); @@ -718,14 +504,9 @@ async function test() { testEditHeaders(); testEditHeadersHeadDirectory(); testEscapeURIPathPreservesDoubleSlashes(); - testReadCredentialsWithAccessSecretKeyAndSessionTokenSet(); - testReadCredentialsFromFilePath(); - testReadCredentialsFromNonexistentPath(); - testReadAndWriteCredentialsFromKeyValStore(); await testEcsCredentialRetrieval(); await testEc2CredentialRetrieval(); - testParseArray(); } test(); -console.log('Finished unit tests'); +console.log('Finished unit tests for s3gateway.js'); diff --git a/test/unit/utils_test.js b/test/unit/utils_test.js new file mode 100644 index 00000000..0f65a3fb --- /dev/null +++ b/test/unit/utils_test.js @@ -0,0 +1,95 @@ +#!env njs + +/* + * Copyright 2023 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import utils from "include/utils.js"; + +function testParseArray() { + printHeader('testParseArray'); + + function testParseNull() { + console.log(' ## testParseNull'); + const actual = utils.parseArray(null); + if (!Array.isArray(actual) || actual.length > 0) { + throw 'Null not parsed into an empty array'; + } + } + function testParseEmptyString() { + console.log(' ## testParseEmptyString'); + const actual = utils.parseArray(''); + if (!Array.isArray(actual) || actual.length > 0) { + throw 'Empty string not parsed into an empty array'; + } + } + function testParseSingleValue() { + console.log(' ## testParseSingleValue'); + const value = 'Single Value'; + const actual = utils.parseArray(value); + if (!Array.isArray(actual) || actual.length !== 1) { + throw 'Single value not parsed into an array with a single element'; + } + if (actual[0] !== value) { + throw `Unexpected array element: ${actual[0]}` + } + } + function testParseMultipleValues() { + console.log(' ## testParseMultipleValues'); + const values = ['string 1', 'something else', 'Yet another value']; + const textValues = values.join(';'); + const actual = utils.parseArray(textValues); + if (!Array.isArray(actual) || actual.length !== values.length) { + throw 'Multiple values not parsed into an array with the expected length'; + } + for (let i = 0; i < values.length; i++) { + if (values[i] !== actual[i]) { + throw `Unexpected array element [${i}]: ${actual[i]}` + } + } + } + + function testParseMultipleValuesTrailingDelimiter() { + console.log(' ## testParseMultipleValuesTrailingDelimiter'); + const values = ['string 1', 'something else', 'Yet another value']; + const textValues = values.join(';'); + const actual = utils.parseArray(textValues + ';'); + if (!Array.isArray(actual) || actual.length !== values.length) { + throw 'Multiple values not parsed into an array with the expected length'; + } + for (let i = 0; i < values.length; i++) { + if (values[i] !== actual[i]) { + throw `Unexpected array element [${i}]: ${actual[i]}` + } + } + } + + testParseNull(); + testParseEmptyString(); + testParseSingleValue(); + testParseMultipleValues(); + testParseMultipleValuesTrailingDelimiter(); +} + +async function test() { + testParseArray(); +} + +function printHeader(testName) { + console.log(`\n## ${testName}`); +} + +test(); +console.log('Finished unit tests for utils.js');