From 27e548c8890b7ee23184dd28f21ef26d5a62c583 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 8 Dec 2025 13:37:17 -0800 Subject: [PATCH 01/10] 7.1.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e249401..71144b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/aio-lib-web", - "version": "7.1.0", + "version": "7.1.1", "description": "Utility tooling library to build and deploy Adobe I/O Project Firefly app static sites to CDN", "main": "index.js", "directories": { From e51850441d50d11cf6757cec5f4619fa7e7877cd Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Fri, 16 Jan 2026 16:36:21 -0800 Subject: [PATCH 02/10] beware the environment, no more S3 lib --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 71144b8..1fa01b0 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@adobe/aio-lib-core-config": "^5", "@adobe/aio-lib-core-logging": "^3", "@adobe/aio-lib-core-tvm": "^4", - "@aws-sdk/client-s3": "^3.624.0", + "@adobe/aio-lib-env": "^3.0.1", "@smithy/node-http-handler": "^4.0.2", "core-js": "^3.25.1", "fs-extra": "^11", From 04d92bc29e03676d73dd44aa55eac5dd3c0b5f21 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Fri, 16 Jan 2026 16:39:25 -0800 Subject: [PATCH 03/10] no more S3 lib credentials needed --- src/deploy-web.js | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/deploy-web.js b/src/deploy-web.js index f140d55..a32daec 100644 --- a/src/deploy-web.js +++ b/src/deploy-web.js @@ -11,7 +11,6 @@ governing permissions and limitations under the License. */ const RemoteStorage = require('../lib/remote-storage') -const getS3Credentials = require('../lib/getS3Creds') const fs = require('fs-extra') const path = require('path') @@ -21,6 +20,11 @@ const deployWeb = async (config, log) => { throw new Error('cannot deploy web, app has no frontend or config is invalid') } + const bearerToken = await config?.ow?.auth_handler?.getAuthHeader() + if (!bearerToken) { + throw new Error('cannot deploy web, Authorization is required') + } + /// build files const dist = config.web.distProd if (!fs.existsSync(dist) || @@ -30,17 +34,8 @@ const deployWeb = async (config, log) => { throw new Error(`missing files in ${dist}, maybe you forgot to build your UI ?`) } - const creds = await getS3Credentials(config) + const remoteStorage = new RemoteStorage() - const remoteStorage = new RemoteStorage(creds) - const exists = await remoteStorage.folderExists(config.s3.folder + '/') - - if (exists) { - if (log) { - log('warning: an existing deployment will be overwritten') - } - await remoteStorage.emptyFolder(config.s3.folder + '/') - } const _log = log ? (f) => log(`deploying ${path.relative(dist, f)}`) : null await remoteStorage.uploadDir(dist, config.s3.folder, config, _log) From 3f12bb8c75b999444b76571dcd802be394d8da53 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Fri, 16 Jan 2026 19:37:14 -0800 Subject: [PATCH 04/10] no more s3 --- lib/getS3Creds.js | 42 --------------------- test/lib/getS3Creds.test.js | 73 ------------------------------------- 2 files changed, 115 deletions(-) delete mode 100644 lib/getS3Creds.js delete mode 100644 test/lib/getS3Creds.test.js diff --git a/lib/getS3Creds.js b/lib/getS3Creds.js deleted file mode 100644 index 6b07f89..0000000 --- a/lib/getS3Creds.js +++ /dev/null @@ -1,42 +0,0 @@ -/* -Copyright 2020 Adobe. All rights reserved. -This file is licensed to you 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 REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ -const TvmClient = require('@adobe/aio-lib-core-tvm') - -const getS3Credentials = async (config) => { - if ( - // byo - !(config.s3 && config.s3.creds) && - // ootb - !(config.ow && config.ow.namespace && config.ow.auth) - ) { - throw new Error('Please check your .env file to ensure your credentials are correct. You can also use "aio app use" to load/refresh your credentials') - } - - if (config.s3 && config.s3.creds) { - return config.s3.creds - } - - const client = await TvmClient.init({ - ow: { - namespace: config.ow.namespace, - auth: config.ow.auth - }, - // can be undefined => defaults in TvmClient - apiUrl: config.s3 && config.s3.tvmUrl, - cacheFile: config.s3 && config.s3.credsCacheFile - }) - - const creds = await client.getAwsS3Credentials() - return creds -} - -module.exports = getS3Credentials diff --git a/test/lib/getS3Creds.test.js b/test/lib/getS3Creds.test.js deleted file mode 100644 index 3afd1c6..0000000 --- a/test/lib/getS3Creds.test.js +++ /dev/null @@ -1,73 +0,0 @@ -/* -Copyright 2020 Adobe. All rights reserved. -This file is licensed to you 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 REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ - -const getS3Credentials = require('../../lib/getS3Creds') - -const fakeReturnedTvmCreds = { fake: 'tvmcreds' } // from __mocks__ -const mockTVM = require('@adobe/aio-lib-core-tvm') - -describe('getS3Credentials', () => { - beforeEach(() => { - mockTVM.init.mockClear() - }) - - test('throw when missing required args', async () => { - const expectedErrorMessage = - 'Please check your .env file to ensure your credentials are correct.' - - await expect(getS3Credentials({})) - .rejects.toThrow(expectedErrorMessage) - - await expect(getS3Credentials({ ow: { namespace: 'ns' }, s3: {} })) - .rejects.toThrow(expectedErrorMessage) - - await expect(getS3Credentials({ ow: { auth: 'auth' } })) - .rejects.toThrow(expectedErrorMessage) - }) - - test('returns s3.creds if defined', async () => { - const fakeCreds = { fake: 's3creds' } - await expect(getS3Credentials({ ow: { namespace: 'ns', auth: 'auth' }, s3: { creds: fakeCreds } })) - .resolves.toEqual(fakeCreds) - expect(mockTVM.init).not.toHaveBeenCalled() - }) - - test('gets credentials from tvm', async () => { - await expect(getS3Credentials({ ow: { namespace: 'ns', auth: 'auth' } })) - .resolves.toEqual(fakeReturnedTvmCreds) - expect(mockTVM.init).toHaveBeenCalledWith({ - apiUrl: undefined, - cacheFile: undefined, - ow: { auth: 'auth', namespace: 'ns' } - }) - }) - - test('gets credentials from tvm with custom tvmurl', async () => { - await expect(getS3Credentials({ ow: { namespace: 'ns', auth: 'auth' }, s3: { tvmUrl: 'custom' } })) - .resolves.toEqual(fakeReturnedTvmCreds) - expect(mockTVM.init).toHaveBeenCalledWith({ - apiUrl: 'custom', - cacheFile: undefined, - ow: { auth: 'auth', namespace: 'ns' } - }) - }) - - test('gets credentials from tvm with custom credsCacheFile', async () => { - await expect(getS3Credentials({ ow: { namespace: 'ns', auth: 'auth' }, s3: { credsCacheFile: 'custom' } })) - .resolves.toEqual(fakeReturnedTvmCreds) - expect(mockTVM.init).toHaveBeenCalledWith({ - apiUrl: undefined, - cacheFile: 'custom', - ow: { auth: 'auth', namespace: 'ns' } - }) - }) -}) From 0ab286237b939ce5b1e6423d1c467d906e41ac49 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 19 Jan 2026 16:47:26 -0800 Subject: [PATCH 05/10] Revert "7.1.1" This reverts commit 27e548c8890b7ee23184dd28f21ef26d5a62c583. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1fa01b0..b4f0579 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/aio-lib-web", - "version": "7.1.1", + "version": "7.1.0", "description": "Utility tooling library to build and deploy Adobe I/O Project Firefly app static sites to CDN", "main": "index.js", "directories": { From ff79d7629190bcbbd44b1642c230e2cba86a7c1e Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 19 Jan 2026 18:10:51 -0800 Subject: [PATCH 06/10] refactor/added authToken to constructor for repeated use --- lib/remote-storage.js | 294 ++++++++++++++++++++++-------------------- 1 file changed, 157 insertions(+), 137 deletions(-) diff --git a/lib/remote-storage.js b/lib/remote-storage.js index 7c202fa..a3d71a2 100644 --- a/lib/remote-storage.js +++ b/lib/remote-storage.js @@ -10,178 +10,170 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const { S3 } = require('@aws-sdk/client-s3') const path = require('path') const mime = require('mime-types') const fs = require('fs-extra') const joi = require('joi') const klaw = require('klaw') const http = require('http') -// Proxy support for AWS SDK v3 (inspired by PR #224 by pat-lego, with compatibility fixes) const { NodeHttpHandler } = require('@smithy/node-http-handler') const { ProxyAgent } = require('proxy-agent') const { codes, logAndThrow } = require('./StorageError') +const { getCliEnv, PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') + + +// or https://deploy-service.dev.app-builder.adp.adobe.io +// or http://localhost:3000 +const deploymentServiceUrl = getCliEnv() === PROD_ENV + ? 'https://deploy-service.app-builder.adp.adobe.io' + : 'https://deploy-service.stg.app-builder.corp.adp.adobe.io' const fileExtensionPattern = /\*\.[0-9a-zA-Z]+$/ -// /** -// * Joins url path parts -// * @param {...string} args url parts -// * @returns {string} -// */ -function urlJoin (...args) { - let start = '' - if (args[0] && - args[0].startsWith('/')) { - start = '/' - } - return start + args.map(a => a && a.replace(/(^\/|\/$)/g, '')) - .filter(a => a) // remove empty strings / nulls - .join('/') -} +// todo: read stage/prod from config and generate the url dynamically +// allow .env setting for the url, for localhost, and/or stage->dev.adobeio-static.net module.exports = class RemoteStorage { - /** - * @param {object} creds - * @param {string} creds.accessKeyId - * @param {string} creds.secretAccessKey - * @param {string} creds.params.Bucket - * @param {string} [creds.sessionToken] - */ - constructor (creds) { - const res = joi.object().keys({ - sessionToken: joi.string(), - accessKeyId: joi.string().required(), - secretAccessKey: joi.string().required(), - // hacky needs s3Bucket in creds.params.Bucket - params: joi.object().keys({ Bucket: joi.string().required() }).required() - }).unknown() - .validate(creds) - if (res.error) { - throw res.error - } - // the TVM response could be passed as is to the v2 client constructor, but the v3 client follows a different format - // see https://github.com/adobe/aio-tvm/issues/85 - const region = creds.region || 'us-east-1' - // note this must supports TVM + BYO use cases - // see https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/interfaces/credentials.html - const credentials = { - accessKeyId: creds.accessKeyId, - secretAccessKey: creds.secretAccessKey, - sessionToken: creds.sessionToken, - expiration: creds.expiration ? new Date(creds.expiration) : undefined - } - this.bucket = creds.params.Bucket + /* +curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-stage/files/' \ + --request DELETE \ + -H 'authorization: Bearer ...' +*/ - // Configure proxy support for AWS SDK v3 - // ProxyAgent automatically handles proxy environment variables via proxy-from-env - const agent = new ProxyAgent() - const s3Config = { - credentials, - region, - requestHandler: new NodeHttpHandler({ - httpAgent: agent, - httpsAgent: agent - }) - } + #authToken; - // https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/classes/s3.html#constructor - this.s3 = new S3(s3Config) +/** + * Constructor for RemoteStorage + * @param {string} authToken - The authorization token to use for the remote storage + */ + constructor(authToken) { + this.#authToken = authToken } /** - * Checks if prefix exists - * @param {string} prefix - * @returns {boolean} + * Checks if any files exist for the namespace + * @param {string} prefix - unused, kept for API compatibility + * @param {Object} appConfig - application config + * @returns {Promise} true if files exist, false otherwise */ - async folderExists (prefix) { + async folderExists (prefix, appConfig) { if (typeof prefix !== 'string') { throw new Error('prefix must be a valid string') } - const listParams = { - Bucket: this.bucket, - Prefix: prefix + if (!this.#authToken) { + throw new Error('cannot check if folder exists, Authorization is required') } - const listedObjects = await this.s3.listObjectsV2(listParams) - - return listedObjects.KeyCount > 0 + // Call the list files endpoint (GET /files) - there is no GET /files/:key route + const response = await fetch(`${deploymentServiceUrl}/cdn-api/namespaces/${appConfig.ow.namespace}/files`, { + method: 'GET', + headers: { + 'Authorization': this.#authToken + } + }) + if (!response.ok) { + return false + } + const files = await response.json() + // Check if there are any files (folder "exists" if it has content) + return Array.isArray(files) && files.length > 0 } + /* +curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-stage/files/test.txt' \ + --request DELETE \ + -H 'authorization: Bearer ...' +*/ + /** - * Deletes all files in a prefix location - * @param {string} prefix + * Empties all files for the namespace or deletes a specific file + * @param {string} prefix - '/' to delete all files, or a specific file path + * @param {Object} appConfig - application config + * @returns {Promise} true if the folder was emptied, false otherwise */ - async emptyFolder (prefix) { - if (typeof prefix !== 'string') throw new Error('prefix must be a valid string') - const listParams = { - Bucket: this.bucket, - Prefix: prefix - } - const listedObjects = await this.s3.listObjectsV2(listParams) - - if (listedObjects.KeyCount < 1) { - return + async emptyFolder (prefix, appConfig) { + if (typeof prefix !== 'string') { + throw new Error('prefix must be a valid string') } - const deleteParams = { - Bucket: this.bucket, - Delete: { Objects: [] } + if (!this.#authToken) { + throw new Error('cannot empty folder, Authorization is required') } - listedObjects.Contents.forEach(({ Key }) => { - deleteParams.Delete.Objects.push({ Key }) + // Server route is DELETE /files/:key + // When key='/' the server triggers emptyStorageForNamespace + // URL construction: /files/ (trailing slash makes :key = '/') + const url = prefix === '/' + ? `${deploymentServiceUrl}/cdn-api/namespaces/${appConfig.ow.namespace}/files/` + : `${deploymentServiceUrl}/cdn-api/namespaces/${appConfig.ow.namespace}/files/${prefix}` + + console.log('url is', url) + const response = await fetch(url, { + method: 'DELETE', + headers: { + 'Authorization': this.#authToken + } }) - await this.s3.deleteObjects(deleteParams) - if (listedObjects.IsTruncated) { - await this.emptyFolder(prefix) - } + return response.ok } + /** - * Uploads a file - * @param {string} file - * @param {string} prefix - prefix to upload the file to + * Uploads a file to the CDN API + * @param {string} file - Full local file path + * @param {string} filePath - Path relative to namespace (e.g., 'images/photo.jpg' or 'index.html') + * This becomes file.name in the API request. The server will prepend the namespace. * @param {Object} appConfig - application config - * @param {string} distRoot - Distribution root dir + * @param {string} distRoot - Distribution root dir (used for header matching) */ - async uploadFile (file, prefix, appConfig, distRoot) { - if (typeof prefix !== 'string') { - throw new Error('prefix must be a valid string') + async uploadFile (file, filePath, appConfig, distRoot, authToken) { + if (typeof filePath !== 'string') { + throw new Error('filePath must be a valid string') } + + const url = `${deploymentServiceUrl}/cdn-api/namespaces/${appConfig.ow.namespace}/files` const content = await fs.readFile(file) const mimeType = mime.lookup(path.extname(file)) // first we will grab it from the global config: htmlCacheDuration, etc. - const cacheControlString = this._getCacheControlConfig(mimeType, appConfig.app) - const uploadParams = { - Bucket: this.bucket, - Key: urlJoin(prefix, path.basename(file)), - Body: content - } - // if we found it in the global config, we will use it ( for now ) - if (cacheControlString) { - uploadParams.CacheControl = cacheControlString - } + let cacheControlString = this._getCacheControlConfig(mimeType, appConfig.app) + // add response headers if specified in manifest const responseHeaders = this.getResponseHeadersForFile(file, distRoot, appConfig) ?? {} // here we allow overriding the cache control if specified in response headers // this is considered more specific than the general cache control config // ideally we deprecate cache control config in favor of response headers directly if (responseHeaders?.['adp-cache-control']) { - uploadParams.CacheControl = responseHeaders['adp-cache-control'] + cacheControlString = responseHeaders['adp-cache-control'] delete responseHeaders['adp-cache-control'] } - - // we only set metadata if we have added anything to responseHeaders object - // it is not null, but could be empty - if (Object.keys(responseHeaders).length > 0) { - uploadParams.Metadata = responseHeaders + // server expected body is: { contentType, cacheControl, customHeaders: {}, file: { name, content } } + // file.name is the path relative to namespace (e.g., 'images/photo.jpg' or 'index.html') + // The server will prepend the namespace to create the S3 key: ${namespace}/${file.name} + const fileName = path.basename(file) + const filePathForServer = filePath === '' ? fileName : `${filePath}/${fileName}` + const data = { + file: { + contentType: mimeType, + cacheControl: cacheControlString, + customHeaders: responseHeaders, + name: filePathForServer, + content: Buffer.from(content).toString('base64') + } } - // s3 misses some mime types like for css files - if (mimeType) { - uploadParams.ContentType = mimeType + const response = await fetch(url, { + method: 'PUT', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + 'Authorization': this.#authToken + } + }).catch(error => { + console.error('Error uploading file:', file) + throw error + }) + if (!response.ok) { + console.error('Failed to upload file:', file) + throw new Error(`Failed to upload file: ${response.statusText}`) } - // Note: putObject is recommended for files < 100MB and has a limit of 5GB, which is ok for our use case of storing static web assets - // if we intend to store larger files, we should use multipart upload and https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/modules/_aws_sdk_lib_storage.html - return this.s3.putObject(uploadParams) + return response.status } getResponseHeadersForFile (file, distRoot, appConfig) { @@ -266,16 +258,18 @@ module.exports = class RemoteStorage { } /** - * Uploads all files in a dir to - recursion is supported - * @param {string} dir - directory with files to upload - * @param {string} prefix - prefix to upload the dir to + * Uploads all files in a directory recursively to the CDN API + * @param {string} dir - Local directory with files to upload + * @param {string} basePath - Base path prefix for all files (e.g., from config.s3.folder) + * This is combined with each file's relative directory path. * @param {Object} appConfig - application config * @param {function} [postFileUploadCallback] - called for each uploaded file */ - async uploadDir (dir, prefix, appConfig, postFileUploadCallback) { - if (typeof prefix !== 'string') { - throw new Error('prefix must be a valid string') + async uploadDir (dir, basePath, appConfig, postFileUploadCallback) { + if (typeof basePath !== 'string') { + throw new Error('basePath must be a valid string') } + // walk the whole directory recursively using klaw. const files = await this.walkDir(dir) @@ -286,25 +280,51 @@ module.exports = class RemoteStorage { const batchSize = 50 let fileBatch = files.splice(0, batchSize) const allResults = [] + if (!this.#authToken) { + throw new Error('cannot upload files, Authorization is required') + } while (fileBatch.length > 0) { - const res = await Promise.all(fileBatch.map(async f => { - // get file's relative folder to the base directory. - let prefixDirectory = path.dirname(path.relative(dir, f)) - // base directory returns ".", ignore that. - prefixDirectory = prefixDirectory === '.' ? '' : prefixDirectory - // newPrefix is now the initial prefix plus the files relative directory path. - const newPrefix = urlJoin(prefix, prefixDirectory) - const s3Res = await this.uploadFile(f, newPrefix, appConfig, dir) + // sleep for 100ms to prevent rate limiting + // await new Promise(resolve => setTimeout(resolve, 100)) + const res = await Promise.all(fileBatch.map(async file => { + // Calculate the file's relative directory path from the base directory + // e.g., if dir='/dist' and file='/dist/images/photo.jpg', relativeDir='images' + let relativeDir = path.dirname(path.relative(dir, file)) + // path.relative returns '.' for files in the root directory, normalize to empty string + relativeDir = relativeDir === '.' ? '' : relativeDir + + // Combine basePath with relativeDir to get the full file path relative to namespace + // e.g., basePath='' + relativeDir='images' = 'images' + // basePath='assets' + relativeDir='images' = 'assets/images' + const filePath = this._urlJoin(basePath, relativeDir) + + // Upload file with the calculated filePath (server will prepend namespace) + const s3Result = await this.uploadFile(file, filePath, appConfig, dir, this.#authToken) if (postFileUploadCallback) { - postFileUploadCallback(f) + postFileUploadCallback(file) } - return s3Res + return s3Result })) allResults.push(res) fileBatch = files.splice(0, batchSize) } return allResults } + /** + * Joins url path parts using URL() methods + * @param {...string} args url parts + * @returns {string} + */ + _urlJoin (...args) { + let start = '' + if (args[0] && + args[0].startsWith('/')) { + start = '/' + } + return start + args.map(a => a && a.replace(/(^\/|\/$)/g, '')) + .filter(a => a) // remove empty strings / nulls + .join('/') + } /** * Get cache control string based on mime type and config From 425228cc8f0dae7683336e2d192b9a62fcf838fc Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 19 Jan 2026 18:52:57 -0800 Subject: [PATCH 07/10] deploy/undeploy files with deploy-service + tests --- src/deploy-web.js | 2 +- src/undeploy-web.js | 12 +- test/jest.setup.js | 28 + test/lib/remote-storage.test.js | 1145 ++++++++++++++++--------------- test/src/deploy-web.test.js | 177 ++--- test/src/undeploy-web.test.js | 47 +- 6 files changed, 713 insertions(+), 698 deletions(-) diff --git a/src/deploy-web.js b/src/deploy-web.js index a32daec..3c2e8c8 100644 --- a/src/deploy-web.js +++ b/src/deploy-web.js @@ -34,7 +34,7 @@ const deployWeb = async (config, log) => { throw new Error(`missing files in ${dist}, maybe you forgot to build your UI ?`) } - const remoteStorage = new RemoteStorage() + const remoteStorage = new RemoteStorage(bearerToken) const _log = log ? (f) => log(`deploying ${path.relative(dist, f)}`) : null await remoteStorage.uploadDir(dist, config.s3.folder, config, _log) diff --git a/src/undeploy-web.js b/src/undeploy-web.js index 5e5361b..858c5fe 100644 --- a/src/undeploy-web.js +++ b/src/undeploy-web.js @@ -11,22 +11,24 @@ governing permissions and limitations under the License. */ const RemoteStorage = require('../lib/remote-storage') -const getS3Credentials = require('../lib/getS3Creds') const undeployWeb = async (config) => { if (!config || !config.app || !config.app.hasFrontend) { throw new Error('cannot undeploy web, app has no frontend or config is invalid') } - const creds = await getS3Credentials(config) + const bearerToken = await config?.ow?.auth_handler?.getAuthHeader() + if (!bearerToken) { + throw new Error('cannot undeploy web, Authorization is required') + } - const remoteStorage = new RemoteStorage(creds) + const remoteStorage = new RemoteStorage(bearerToken) - if (!(await remoteStorage.folderExists(config.s3.folder + '/'))) { + if (!(await remoteStorage.folderExists('/', config))) { throw new Error(`cannot undeploy static files, there is no deployment for ${config.s3.folder}`) } - await remoteStorage.emptyFolder(config.s3.folder + '/') + await remoteStorage.emptyFolder('/', config) } module.exports = undeployWeb diff --git a/test/jest.setup.js b/test/jest.setup.js index 55a3a25..86368e4 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -119,6 +119,30 @@ global.configWithModifiedWeb = (config, newWebConfig) => { } global.fakeS3Bucket = 'fake-bucket' +global.fakeNamespace = 'fake-namespace' +global.fakeAuthToken = 'Bearer fake-auth-token' + +// Config structure for deploy-service API (auth token is now passed to RemoteStorage constructor) +global.fakeAppConfig = { + ow: { + namespace: global.fakeNamespace + }, + app: { + htmlCacheDuration: 60, + jsCacheDuration: 604800, + cssCacheDuration: 604800, + imageCacheDuration: 604800 + }, + web: { + 'response-headers': { + '/*': { + testHeader: 'foo' + } + } + } +} + +// Legacy config structure (kept for backwards compatibility with other tests) global.fakeConfig = { tvm: { runtime: { @@ -157,6 +181,10 @@ global.fakeConfig = { testHeader: 'foo' } } + }, + // Add ow property to legacy config for tests that use both + ow: { + namespace: global.fakeNamespace } } diff --git a/test/lib/remote-storage.test.js b/test/lib/remote-storage.test.js index 8086c3f..467759e 100644 --- a/test/lib/remote-storage.test.js +++ b/test/lib/remote-storage.test.js @@ -10,64 +10,49 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const mockS3 = { - listObjectsV2: jest.fn(), - deleteObjects: jest.fn(), - putObject: jest.fn() -} - -jest.mock('@aws-sdk/client-s3', () => Object({ S3: jest.fn(() => { return mockS3 }) })) - -const { S3 } = require('@aws-sdk/client-s3') const { vol } = global.mockFs() const path = require('path') const RemoteStorage = require('../../lib/remote-storage') +// Mock fetch globally +global.fetch = jest.fn() + +// Helper to create a mock response +const mockResponse = (body, options = {}) => ({ + ok: options.ok !== false, + status: options.status || 200, + statusText: options.statusText || 'OK', + json: jest.fn().mockResolvedValue(body) +}) + +// Helper to create appConfig (auth token now passed to constructor, not in config) +const createAppConfig = (overrides = {}) => ({ + ow: { + namespace: global.fakeNamespace + }, + app: { + htmlCacheDuration: 60, + jsCacheDuration: 604800, + cssCacheDuration: 604800, + imageCacheDuration: 604800 + }, + web: { + 'response-headers': { + '/*': { + testHeader: 'foo' + } + } + }, + ...overrides +}) + describe('RemoteStorage', () => { beforeEach(() => { - // resets all mock s3 functions, do not use jest.resetAllMocks() as it also resets the s3 client constructor mock - mockS3.listObjectsV2.mockReset() - mockS3.deleteObjects.mockReset() - mockS3.putObject.mockReset() - S3.mockClear() // resets the mock fs global.cleanFs(vol) - }) - - test('Constructor should throw when missing credentials', async () => { - const instantiate = () => new RemoteStorage({}) - expect(instantiate.bind(this)).toThrowWithMessageContaining(['required']) - }) - - test('Constructor initializes the S3 constructor properly using tvm credentials', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - expect(S3).toHaveBeenCalledWith({ - credentials: { - accessKeyId: global.fakeTVMResponse.accessKeyId, - secretAccessKey: global.fakeTVMResponse.secretAccessKey, - sessionToken: global.fakeTVMResponse.sessionToken, - expiration: new Date(global.fakeTVMResponse.expiration) - }, - region: 'us-east-1', - requestHandler: expect.any(Object) - }) - rs.bucket = global.fakeTVMResponse.Bucket - }) - - test('Constructor initializes the S3 constructor properly using byo credentials', async () => { - const rs = new RemoteStorage(global.fakeBYOCredentials) - expect(S3).toHaveBeenCalledWith({ - credentials: { - accessKeyId: global.fakeTVMResponse.accessKeyId, - secretAccessKey: global.fakeTVMResponse.secretAccessKey, - sessionToken: undefined, - expiration: undefined - }, - region: 'us-east-1', - requestHandler: expect.any(Object) - }) - rs.bucket = global.fakeTVMResponse.Bucket + // reset fetch mock + global.fetch.mockReset() }) describe('Proxy configuration', () => { @@ -88,603 +73,677 @@ describe('RemoteStorage', () => { test('Constructor uses HTTPS_PROXY when set (uppercase)', async () => { process.env.HTTPS_PROXY = 'http://proxy.example.com:8080' - // eslint-disable-next-line no-new - new RemoteStorage(global.fakeTVMResponse) - - expect(S3).toHaveBeenCalledWith(expect.objectContaining({ - requestHandler: expect.any(Object), - credentials: expect.any(Object), - region: 'us-east-1' - })) + const rs = new RemoteStorage(global.fakeAuthToken) + expect(rs).toBeDefined() }) test('Constructor uses https_proxy when set (lowercase)', async () => { process.env.https_proxy = 'http://proxy.example.com:3128' - // eslint-disable-next-line no-new - new RemoteStorage(global.fakeTVMResponse) - - expect(S3).toHaveBeenCalledWith(expect.objectContaining({ - requestHandler: expect.any(Object), - credentials: expect.any(Object), - region: 'us-east-1' - })) + const rs = new RemoteStorage(global.fakeAuthToken) + expect(rs).toBeDefined() }) test('Constructor uses HTTP_PROXY when HTTPS_PROXY not set', async () => { process.env.HTTP_PROXY = 'http://proxy.example.com:8080' - // eslint-disable-next-line no-new - new RemoteStorage(global.fakeTVMResponse) - - expect(S3).toHaveBeenCalledWith(expect.objectContaining({ - requestHandler: expect.any(Object), - credentials: expect.any(Object), - region: 'us-east-1' - })) + const rs = new RemoteStorage(global.fakeAuthToken) + expect(rs).toBeDefined() }) test('Constructor uses http_proxy when other proxy vars not set', async () => { process.env.http_proxy = 'http://proxy.example.com:3128' - // eslint-disable-next-line no-new - new RemoteStorage(global.fakeTVMResponse) - - expect(S3).toHaveBeenCalledWith(expect.objectContaining({ - requestHandler: expect.any(Object), - credentials: expect.any(Object), - region: 'us-east-1' - })) + const rs = new RemoteStorage(global.fakeAuthToken) + expect(rs).toBeDefined() }) test('Constructor prioritizes HTTPS_PROXY over HTTP_PROXY', async () => { process.env.HTTPS_PROXY = 'http://https-proxy.example.com:8080' process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080' - // eslint-disable-next-line no-new - new RemoteStorage(global.fakeTVMResponse) - - expect(S3).toHaveBeenCalledWith(expect.objectContaining({ - requestHandler: expect.any(Object), - credentials: expect.any(Object), - region: 'us-east-1' - })) + const rs = new RemoteStorage(global.fakeAuthToken) + expect(rs).toBeDefined() }) test('Constructor prioritizes https_proxy over HTTP_PROXY', async () => { process.env.https_proxy = 'http://https-proxy.example.com:3128' process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080' - // eslint-disable-next-line no-new - new RemoteStorage(global.fakeTVMResponse) + const rs = new RemoteStorage(global.fakeAuthToken) + expect(rs).toBeDefined() + }) + }) - expect(S3).toHaveBeenCalledWith(expect.objectContaining({ - requestHandler: expect.any(Object), - credentials: expect.any(Object), - region: 'us-east-1' - })) + describe('folderExists', () => { + test('missing prefix should throw', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + await expect(rs.folderExists(undefined, appConfig)).rejects.toEqual( + expect.objectContaining({ message: 'prefix must be a valid string' }) + ) }) - test('Constructor always includes requestHandler with ProxyAgent', async () => { - // eslint-disable-next-line no-new - new RemoteStorage(global.fakeTVMResponse) + test('should return false if there are no files', async () => { + global.fetch.mockResolvedValue(mockResponse([])) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + + const result = await rs.folderExists('fakeprefix', appConfig) + + expect(result).toBe(false) + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/cdn-api/namespaces/${global.fakeNamespace}/files`), + expect.objectContaining({ + method: 'GET', + headers: { Authorization: global.fakeAuthToken } + }) + ) + }) - expect(S3).toHaveBeenCalledWith({ - credentials: expect.any(Object), - region: 'us-east-1', - requestHandler: expect.any(Object) - }) + test('should return true if there are files', async () => { + global.fetch.mockResolvedValue(mockResponse([{ key: 'file1.txt' }])) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() - // ProxyAgent handles proxy detection automatically via proxy-from-env - const s3CallArgs = S3.mock.calls[S3.mock.calls.length - 1][0] - expect(s3CallArgs).toHaveProperty('requestHandler') + const result = await rs.folderExists('fakeprefix', appConfig) + + expect(result).toBe(true) }) - }) - test('folderExists missing prefix', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - await expect(rs.folderExists()).rejects.toEqual(expect.objectContaining({ message: 'prefix must be a valid string' })) - }) + test('should return false if request fails', async () => { + global.fetch.mockResolvedValue(mockResponse(null, { ok: false, status: 500 })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() - test('emptyFolder missing prefix', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - await expect(rs.emptyFolder()).rejects.toEqual(expect.objectContaining({ message: 'prefix must be a valid string' })) - }) + const result = await rs.folderExists('fakeprefix', appConfig) - test('uploadFile missing prefix', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - await expect(rs.uploadFile()).rejects.toEqual(expect.objectContaining({ message: 'prefix must be a valid string' })) - }) + expect(result).toBe(false) + }) - test('uploadDir missing prefix', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - await expect(rs.uploadDir()).rejects.toEqual(expect.objectContaining({ message: 'prefix must be a valid string' })) - }) + test('should throw if no auth token', async () => { + const rs = new RemoteStorage(null) + const appConfig = createAppConfig() - test('folderExists should return false if there are no files', async () => { - mockS3.listObjectsV2.mockResolvedValue({ KeyCount: 0 }) - const rs = new RemoteStorage(global.fakeTVMResponse) - expect((await rs.folderExists('fakeprefix'))).toBe(false) - expect(mockS3.listObjectsV2).toHaveBeenCalledWith({ Bucket: 'fake-bucket', Prefix: 'fakeprefix' }) + await expect(rs.folderExists('fakeprefix', appConfig)).rejects.toThrow( + 'cannot check if folder exists, Authorization is required' + ) + }) }) - test('folderExists should return true if there are files', async () => { - mockS3.listObjectsV2.mockResolvedValue({ KeyCount: 1 }) - const rs = new RemoteStorage(global.fakeTVMResponse) - expect((await rs.folderExists('fakeprefix'))).toBe(true) - }) + describe('emptyFolder', () => { + test('missing prefix should throw', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + await expect(rs.emptyFolder(undefined, appConfig)).rejects.toEqual( + expect.objectContaining({ message: 'prefix must be a valid string' }) + ) + }) - test('emptyFolder should not throw if there are no files', async () => { - mockS3.listObjectsV2.mockResolvedValue({ KeyCount: 0 }) - const rs = new RemoteStorage(global.fakeTVMResponse) - expect(rs.emptyFolder.bind(rs, 'fakeprefix')).not.toThrow() - }) + test('should call DELETE /files/ when prefix is "/"', async () => { + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + + const result = await rs.emptyFolder('/', appConfig) + + expect(result).toBe(true) + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/cdn-api/namespaces/${global.fakeNamespace}/files/`), + expect.objectContaining({ + method: 'DELETE', + headers: { Authorization: global.fakeAuthToken } + }) + ) + }) - test('emptyFolder should not call S3#deleteObjects if already empty', async () => { - mockS3.listObjectsV2.mockResolvedValue({ KeyCount: 0 }) - const rs = new RemoteStorage(global.fakeTVMResponse) - await rs.emptyFolder('fakeprefix') - expect(mockS3.deleteObjects).toHaveBeenCalledTimes(0) - }) + test('should call DELETE /files/:key for specific file', async () => { + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() - test('emptyFolder should call S3#deleteObjects with correct parameters with one file', async () => { - const content = [{ Key: 'fakeprefix/index.html' }] - mockS3.listObjectsV2.mockResolvedValue({ KeyCount: 1, Contents: content }) - const rs = new RemoteStorage(global.fakeTVMResponse) - await rs.emptyFolder('fakeprefix') - expect(mockS3.deleteObjects).toHaveBeenCalledWith({ Bucket: 'fake-bucket', Delete: { Objects: content } }) - }) + const result = await rs.emptyFolder('path/to/file.txt', appConfig) - test('emptyFolder should call S3#deleteObjects with correct parameters with multiple files', async () => { - const content = [{ Key: 'fakeprefix/index.html' }, { Key: 'fakeprefix/index.css' }, { Key: 'fakeprefix/index.css' }] - mockS3.listObjectsV2.mockResolvedValue({ KeyCount: 3, Contents: content }) - const rs = new RemoteStorage(global.fakeTVMResponse) - await rs.emptyFolder('fakeprefix') - expect(mockS3.deleteObjects).toHaveBeenCalledWith({ Bucket: 'fake-bucket', Delete: { Objects: content } }) - }) + expect(result).toBe(true) + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/cdn-api/namespaces/${global.fakeNamespace}/files/path/to/file.txt`), + expect.objectContaining({ method: 'DELETE' }) + ) + }) - test('emptyFolder should call S3#deleteObjects multiple time if listObjects is truncated', async () => { - const content = [{ Key: 'fakeprefix/index.html' }, { Key: 'fakeprefix/index.css' }, { Key: 'fakeprefix/index.js' }] - let iterations = 2 - mockS3.listObjectsV2.mockImplementation(() => { - const res = { Contents: [content[iterations]], IsTruncated: iterations > 0 } - iterations-- - return Promise.resolve(res) - }) - const rs = new RemoteStorage(global.fakeTVMResponse) - await rs.emptyFolder('fakeprefix') - expect(mockS3.deleteObjects).toHaveBeenCalledWith({ Bucket: 'fake-bucket', Delete: { Objects: [content[0]] } }) - expect(mockS3.deleteObjects).toHaveBeenCalledWith({ Bucket: 'fake-bucket', Delete: { Objects: [content[1]] } }) - expect(mockS3.deleteObjects).toHaveBeenCalledWith({ Bucket: 'fake-bucket', Delete: { Objects: [content[2]] } }) - }) + test('should return false if delete fails', async () => { + global.fetch.mockResolvedValue(mockResponse(null, { ok: false, status: 500 })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() - test('uploadFile should call S3#upload with the correct parameters', async () => { - global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) - const rs = new RemoteStorage(global.fakeTVMResponse) - const fakeConfig = global.fakeConfig - await rs.uploadFile('fakeDir/index.js', 'fakeprefix', fakeConfig, 'fakeDir') - const body = Buffer.from('fake content', 'utf8') - expect(mockS3.putObject).toHaveBeenCalledWith(expect.objectContaining({ Bucket: 'fake-bucket', Key: 'fakeprefix/index.js', Body: body, ContentType: 'application/javascript' })) - }) + const result = await rs.emptyFolder('/', appConfig) - test('uploadFile should call S3#upload with the correct parameters and slash-prefix', async () => { - global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) - const rs = new RemoteStorage(global.fakeTVMResponse) - const fakeConfig = global.fakeConfig - await rs.uploadFile('fakeDir/index.js', '/slash-prefix', fakeConfig, 'fakeDir') - const body = Buffer.from('fake content', 'utf8') - expect(mockS3.putObject).toHaveBeenCalledWith(expect.objectContaining({ Bucket: 'fake-bucket', Key: '/slash-prefix/index.js', Body: body, ContentType: 'application/javascript' })) - }) + expect(result).toBe(false) + }) - test('uploadFile S3#upload with an unknown Content-Type', async () => { - global.addFakeFiles(vol, 'fakeDir', { 'index.mst': 'fake content' }) - const rs = new RemoteStorage(global.fakeTVMResponse) - const fakeConfig = {} - await rs.uploadFile('fakeDir/index.mst', 'fakeprefix', fakeConfig, 'fakeDir') - const body = Buffer.from('fake content', 'utf8') - expect(mockS3.putObject).toHaveBeenCalledWith(expect.objectContaining({ Bucket: 'fake-bucket', Key: 'fakeprefix/index.mst', Body: body })) - expect(mockS3.putObject.mock.calls[0][0]).not.toHaveProperty('ContentType') - }) + test('should throw if no auth token', async () => { + const rs = new RemoteStorage(null) + const appConfig = createAppConfig() - test('uploadDir should call S3#upload one time per file', async () => { - await global.addFakeFiles(vol, 'fakeDir', ['index.js', 'index.css', 'index.html']) - const rs = new RemoteStorage(global.fakeTVMResponse) - await rs.uploadDir('fakeDir', 'fakeprefix', global.fakeConfig) - expect(mockS3.putObject).toHaveBeenCalledTimes(3) + await expect(rs.emptyFolder('/', appConfig)).rejects.toThrow( + 'cannot empty folder, Authorization is required' + ) + }) }) - test('uploadDir should call a callback once per uploaded file', async () => { - await global.addFakeFiles(vol, 'fakeDir', ['index.js', 'index.css', 'index.html', 'test/i.js']) - const cbMock = jest.fn() - const rs = new RemoteStorage(global.fakeTVMResponse) + describe('uploadFile', () => { + test('missing filePath should throw', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + await expect(rs.uploadFile('file.txt', undefined, appConfig, 'dist')).rejects.toEqual( + expect.objectContaining({ message: 'filePath must be a valid string' }) + ) + }) - await rs.uploadDir('fakeDir', 'fakeprefix', global.fakeConfig, cbMock) - expect(cbMock).toHaveBeenCalledTimes(4) - }) + test('should call PUT /files with correct parameters', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + + await rs.uploadFile('fakeDir/index.js', 'fakeprefix', appConfig, 'fakeDir') + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/cdn-api/namespaces/${global.fakeNamespace}/files`), + expect.objectContaining({ + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: global.fakeAuthToken + } + }) + ) + + // Verify the body contains expected data + const callArgs = global.fetch.mock.calls[0] + const body = JSON.parse(callArgs[1].body) + expect(body.file).toMatchObject({ + name: 'fakeprefix/index.js', + contentType: 'application/javascript' + }) + expect(body.file.content).toBeDefined() // base64 encoded content + }) - test('cachecontrol string for html', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const response = rs._getCacheControlConfig('text/html', global.fakeConfig.app) - expect(response).toBe('s-maxage=60, max-age=60') - }) + test('should call PUT /files with slash-prefix', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() - test('cachecontrol string for JS', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const response = rs._getCacheControlConfig('application/javascript', global.fakeConfig.app) - expect(response).toBe('s-maxage=60, max-age=604800') - }) + await rs.uploadFile('fakeDir/index.js', '/slash-prefix', appConfig, 'fakeDir') - test('cachecontrol string for CSS', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const response = rs._getCacheControlConfig('text/css', global.fakeConfig.app) - expect(response).toBe('s-maxage=60, max-age=604800') - }) + const callArgs = global.fetch.mock.calls[0] + const body = JSON.parse(callArgs[1].body) + expect(body.file.name).toBe('/slash-prefix/index.js') + }) - test('cachecontrol string for Image', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const response = rs._getCacheControlConfig('image/jpeg', global.fakeConfig.app) - expect(response).toBe('s-maxage=60, max-age=604800') - }) + test('should handle unknown Content-Type', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.mst': 'fake content' }) + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() - test('cachecontrol string for default', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const response = rs._getCacheControlConfig('application/pdf', global.fakeConfig.app) - expect(response).toBe(null) - }) + await rs.uploadFile('fakeDir/index.mst', 'fakeprefix', appConfig, 'fakeDir') - test('cachecontrol string for html when htmlCacheDuration is not defined', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const appConfigWithoutHtmlCache = global.configWithMissing(global.fakeConfig.app, 'htmlCacheDuration') - const response = rs._getCacheControlConfig('text/html', appConfigWithoutHtmlCache) - expect(response).toBe(null) - }) + const callArgs = global.fetch.mock.calls[0] + const body = JSON.parse(callArgs[1].body) + expect(body.file.name).toBe('fakeprefix/index.mst') + // contentType will be false for unknown extensions + expect(body.file.contentType).toBe(false) + }) - test('cachecontrol string for JS when jsCacheDuration is not defined', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const appConfigWithoutJsCache = global.configWithMissing(global.fakeConfig.app, 'jsCacheDuration') - const response = rs._getCacheControlConfig('application/javascript', appConfigWithoutJsCache) - expect(response).toBe(null) - }) + test('should handle empty filePath (file at root)', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() - test('cachecontrol string for CSS when cssCacheDuration is not defined', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const appConfigWithoutCssCache = global.configWithMissing(global.fakeConfig.app, 'cssCacheDuration') - const response = rs._getCacheControlConfig('text/css', appConfigWithoutCssCache) - expect(response).toBe(null) - }) + await rs.uploadFile('fakeDir/index.js', '', appConfig, 'fakeDir') + + const callArgs = global.fetch.mock.calls[0] + const body = JSON.parse(callArgs[1].body) + // When filePath is empty, file.name should just be the filename + expect(body.file.name).toBe('index.js') + }) - test('cachecontrol string for image when imageCacheDuration is not defined', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const appConfigWithoutImageCache = global.configWithMissing(global.fakeConfig.app, 'imageCacheDuration') - const response = rs._getCacheControlConfig('image/jpeg', appConfigWithoutImageCache) - expect(response).toBe(null) + test('should throw if upload fails', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) + global.fetch.mockResolvedValue(mockResponse(null, { ok: false, status: 500, statusText: 'Internal Server Error' })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + + await expect( + rs.uploadFile('fakeDir/index.js', 'fakeprefix', appConfig, 'fakeDir') + ).rejects.toThrow('Failed to upload file: Internal Server Error') + }) + + test('should throw if fetch itself throws (network error)', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) + global.fetch.mockRejectedValue(new Error('Network error')) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + + await expect( + rs.uploadFile('fakeDir/index.js', 'fakeprefix', appConfig, 'fakeDir') + ).rejects.toThrow('Network error') + }) }) - // response header tests - test('get response header from config with multiple rules', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const newConfig = global.configWithModifiedWeb(global.fakeConfig, { - 'response-headers': { - '/*': { - testHeader: 'generic-header' - }, - '/testFolder/*': { - testHeader: 'folder-header' - }, - '/testFolder/*.js': { - testHeader: 'all-js-file-in-folder-header' - }, - '/test.js': { - testHeader: 'specific-file-header' - } - } + describe('uploadDir', () => { + test('missing basePath should throw', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + await expect(rs.uploadDir('fakeDir', undefined, appConfig)).rejects.toEqual( + expect.objectContaining({ message: 'basePath must be a valid string' }) + ) }) - const folderPath1 = 'testFolder' + path.sep + 'index.html' - const folderPath2 = 'testFolder' + path.sep + 'test.js' - await global.addFakeFiles(vol, 'fakeDir', ['index.html', 'test.js', folderPath1, folderPath2]) - const files = await rs.walkDir('fakeDir') - const fakeDistRoot = path.parse(files[0]).dir + test('should upload all files in directory', async () => { + global.addFakeFiles(vol, 'fakeDir', ['index.js', 'index.css', 'index.html']) + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() - const expectedValMap = { - 'index.html': { 'adp-testHeader': 'generic-header' }, - 'test.js': { 'adp-testHeader': 'specific-file-header' } - } - expectedValMap[folderPath1] = { 'adp-testHeader': 'folder-header' } - expectedValMap[folderPath2] = { 'adp-testHeader': 'all-js-file-in-folder-header' } + await rs.uploadDir('fakeDir', 'fakeprefix', appConfig) - files.forEach(f => { - const fileName = f.replace(path.join(fakeDistRoot, path.sep), '') - const response = rs.getResponseHeadersForFile(f, fakeDistRoot, newConfig) - const expected = expectedValMap[fileName] - expect(response).toStrictEqual(expected) + // Should have called fetch for each file + expect(global.fetch).toHaveBeenCalledTimes(3) + }) + + test('should call callback once per uploaded file', async () => { + global.addFakeFiles(vol, 'fakeDir', ['index.js', 'index.css', 'index.html', 'test/i.js']) + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const cbMock = jest.fn() + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + + await rs.uploadDir('fakeDir', 'fakeprefix', appConfig, cbMock) + + expect(cbMock).toHaveBeenCalledTimes(4) + }) + + test('should throw if no auth token', async () => { + global.addFakeFiles(vol, 'fakeDir', ['index.js']) + const rs = new RemoteStorage(null) + const appConfig = createAppConfig() + + await expect(rs.uploadDir('fakeDir', 'fakeprefix', appConfig)).rejects.toThrow( + 'cannot upload files, Authorization is required' + ) }) }) - test('get response header for folder based path rules', async () => { - // setup files and paths - const rs = new RemoteStorage(global.fakeTVMResponse) - const folderPath1 = 'css' + path.sep + 'ui.css' - const folderPath2 = 'scripts' + path.sep + 'test.js' - const folderPath3 = 'images' + path.sep + 'image.png' - const folderPath4 = 'images' + path.sep + 'thumbnails' + path.sep + 'test.jpeg' - await global.addFakeFiles(vol, 'fakeDir', ['index.html', 'test.js', folderPath1, folderPath2, folderPath3, folderPath4]) - const files = await rs.walkDir('fakeDir') - const fakeDistRoot = path.parse(files[0]).dir - - // create a config of rules for files in specific folder - const newConfig = global.configWithModifiedWeb(global.fakeConfig, { - 'response-headers': { - '/*': { - testHeader: 'generic-header' - }, - '/css/*': { - testHeader: 'all-files-in-css-folder-header' - }, - '/scripts/*': { - testHeader: 'all-files-in-js-folder-header' - }, - '/images/*': { - testHeader: 'all-files-in-images-folder-header' - } - } + describe('_urlJoin', () => { + test('joins paths without leading slash', () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const result = rs._urlJoin('path', 'to', 'file') + expect(result).toBe('path/to/file') }) - // set the expectation - const expectedValMap = { - 'index.html': { 'adp-testHeader': 'generic-header' }, - 'test.js': { 'adp-testHeader': 'generic-header' } - } - expectedValMap[folderPath1] = { 'adp-testHeader': 'all-files-in-css-folder-header' } - expectedValMap[folderPath2] = { 'adp-testHeader': 'all-files-in-js-folder-header' } - expectedValMap[folderPath3] = { 'adp-testHeader': 'all-files-in-images-folder-header' } - expectedValMap[folderPath4] = { 'adp-testHeader': 'all-files-in-images-folder-header' } - - // check header application per file - files.forEach(f => { - const fileName = f.replace(path.join(fakeDistRoot, path.sep), '') - const response = rs.getResponseHeadersForFile(f, fakeDistRoot, newConfig) - const expected = expectedValMap[fileName] - expect(response).toStrictEqual(expected) + test('preserves leading slash when first arg starts with /', () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const result = rs._urlJoin('/leading', 'path', 'file') + expect(result).toBe('/leading/path/file') + }) + + test('handles empty strings and nulls', () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const result = rs._urlJoin('path', '', null, 'file') + expect(result).toBe('path/file') }) }) - test('get response header for specific file based path rules', async () => { - // setup files and paths - const rs = new RemoteStorage(global.fakeTVMResponse) - const folderPath1 = 'css' + path.sep + 'ui.css' - const folderPath2 = 'scripts' + path.sep + 'test.js' - const folderPath3 = 'images' + path.sep + 'image.png' - await global.addFakeFiles(vol, 'fakeDir', ['index.html', 'test.js', folderPath1, folderPath2, folderPath3]) - const files = await rs.walkDir('fakeDir') - const fakeDistRoot = path.parse(files[0]).dir - - // create a config of rules for spcefic files which overrider folder rules - const newConfig = global.configWithModifiedWeb(global.fakeConfig, { - 'response-headers': { - '/*': { - testHeader: 'generic-header' - }, - '/css/*': { - testHeader: 'all-files-in-css-folder-header' - }, - '/css/ui.css': { - testHeader: 'specific-css-file-header' // overrides previous css folder rule - }, - '/scripts/*': { - testHeader: 'all-files-in-js-folder-header' - }, - '/scripts/test.js': { - testHeader: 'specific-js-file-header' // overrides previous js folder rule - }, - '/images/*': { - testHeader: 'all-files-in-images-folder-header' - }, - '/images/image.png': { - testHeader: 'specific-image-file-header' // overrides previous image folder rule - } - } + describe('cache control', () => { + test('cachecontrol string for html', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const response = rs._getCacheControlConfig('text/html', global.fakeConfig.app) + expect(response).toBe('s-maxage=60, max-age=60') }) - // set the expectation - const expectedValMap = { - 'index.html': { 'adp-testHeader': 'generic-header' }, - 'test.js': { 'adp-testHeader': 'generic-header' } - } - expectedValMap[folderPath1] = { 'adp-testHeader': 'specific-css-file-header' } - expectedValMap[folderPath2] = { 'adp-testHeader': 'specific-js-file-header' } - expectedValMap[folderPath3] = { 'adp-testHeader': 'specific-image-file-header' } + test('cachecontrol string for JS', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const response = rs._getCacheControlConfig('application/javascript', global.fakeConfig.app) + expect(response).toBe('s-maxage=60, max-age=604800') + }) + + test('cachecontrol string for CSS', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const response = rs._getCacheControlConfig('text/css', global.fakeConfig.app) + expect(response).toBe('s-maxage=60, max-age=604800') + }) + + test('cachecontrol string for Image', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const response = rs._getCacheControlConfig('image/jpeg', global.fakeConfig.app) + expect(response).toBe('s-maxage=60, max-age=604800') + }) + + test('cachecontrol string for default', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const response = rs._getCacheControlConfig('application/pdf', global.fakeConfig.app) + expect(response).toBe(null) + }) + + test('cachecontrol string for html when htmlCacheDuration is not defined', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfigWithoutHtmlCache = global.configWithMissing(global.fakeConfig.app, 'htmlCacheDuration') + const response = rs._getCacheControlConfig('text/html', appConfigWithoutHtmlCache) + expect(response).toBe(null) + }) + + test('cachecontrol string for JS when jsCacheDuration is not defined', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfigWithoutJsCache = global.configWithMissing(global.fakeConfig.app, 'jsCacheDuration') + const response = rs._getCacheControlConfig('application/javascript', appConfigWithoutJsCache) + expect(response).toBe(null) + }) + + test('cachecontrol string for CSS when cssCacheDuration is not defined', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfigWithoutCssCache = global.configWithMissing(global.fakeConfig.app, 'cssCacheDuration') + const response = rs._getCacheControlConfig('text/css', appConfigWithoutCssCache) + expect(response).toBe(null) + }) - // check header application per file - files.forEach(f => { - const fileName = f.replace(path.join(fakeDistRoot, path.sep), '') - const response = rs.getResponseHeadersForFile(f, fakeDistRoot, newConfig) - const expected = expectedValMap[fileName] - expect(response).toStrictEqual(expected) + test('cachecontrol string for image when imageCacheDuration is not defined', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfigWithoutImageCache = global.configWithMissing(global.fakeConfig.app, 'imageCacheDuration') + const response = rs._getCacheControlConfig('image/jpeg', appConfigWithoutImageCache) + expect(response).toBe(null) }) }) - test('get response header for file extension based path rules', async () => { - // setup files and paths - const rs = new RemoteStorage(global.fakeTVMResponse) - const folderPath1 = 'css' + path.sep + 'ui.css' - const folderPath2 = 'scripts' + path.sep + 'test.js' - const folderPath3 = 'images' + path.sep + 'image.png' - await global.addFakeFiles(vol, 'fakeDir', ['index.html', 'test.js', folderPath1, folderPath2, folderPath3]) - const files = await rs.walkDir('fakeDir') - const fakeDistRoot = path.parse(files[0]).dir - - // create a config of rules for spcefic files which overrider folder rules - const newConfig = global.configWithModifiedWeb(global.fakeConfig, { - 'response-headers': { - '/*': { - testHeader: 'generic-header' - }, - '/*.css': { - testHeader: 'all-css-files-header' - }, - '/*.js': { - testHeader: 'all-js-files-header' - }, - '/*.png': { - testHeader: 'all-png-files-header' + describe('response headers', () => { + test('get response header from config with multiple rules', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const newConfig = global.configWithModifiedWeb(global.fakeConfig, { + 'response-headers': { + '/*': { + testHeader: 'generic-header' + }, + '/testFolder/*': { + testHeader: 'folder-header' + }, + '/testFolder/*.js': { + testHeader: 'all-js-file-in-folder-header' + }, + '/test.js': { + testHeader: 'specific-file-header' + } } + }) + + const folderPath1 = 'testFolder' + path.sep + 'index.html' + const folderPath2 = 'testFolder' + path.sep + 'test.js' + await global.addFakeFiles(vol, 'fakeDir', ['index.html', 'test.js', folderPath1, folderPath2]) + const files = await rs.walkDir('fakeDir') + const fakeDistRoot = path.parse(files[0]).dir + + const expectedValMap = { + 'index.html': { 'adp-testHeader': 'generic-header' }, + 'test.js': { 'adp-testHeader': 'specific-file-header' } } + expectedValMap[folderPath1] = { 'adp-testHeader': 'folder-header' } + expectedValMap[folderPath2] = { 'adp-testHeader': 'all-js-file-in-folder-header' } + + files.forEach(f => { + const fileName = f.replace(path.join(fakeDistRoot, path.sep), '') + const response = rs.getResponseHeadersForFile(f, fakeDistRoot, newConfig) + const expected = expectedValMap[fileName] + expect(response).toStrictEqual(expected) + }) }) - // set the expectation - const expectedValMap = { - 'index.html': { 'adp-testHeader': 'generic-header' }, - 'test.js': { 'adp-testHeader': 'all-js-files-header' } - } - expectedValMap[folderPath1] = { 'adp-testHeader': 'all-css-files-header' } - expectedValMap[folderPath2] = { 'adp-testHeader': 'all-js-files-header' } - expectedValMap[folderPath3] = { 'adp-testHeader': 'all-png-files-header' } + test('get response header for folder based path rules', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const folderPath1 = 'css' + path.sep + 'ui.css' + const folderPath2 = 'scripts' + path.sep + 'test.js' + const folderPath3 = 'images' + path.sep + 'image.png' + const folderPath4 = 'images' + path.sep + 'thumbnails' + path.sep + 'test.jpeg' + await global.addFakeFiles(vol, 'fakeDir', ['index.html', 'test.js', folderPath1, folderPath2, folderPath3, folderPath4]) + const files = await rs.walkDir('fakeDir') + const fakeDistRoot = path.parse(files[0]).dir + + const newConfig = global.configWithModifiedWeb(global.fakeConfig, { + 'response-headers': { + '/*': { + testHeader: 'generic-header' + }, + '/css/*': { + testHeader: 'all-files-in-css-folder-header' + }, + '/scripts/*': { + testHeader: 'all-files-in-js-folder-header' + }, + '/images/*': { + testHeader: 'all-files-in-images-folder-header' + } + } + }) - // check header application per file - files.forEach(f => { - const fileName = f.replace(path.join(fakeDistRoot, path.sep), '') - const response = rs.getResponseHeadersForFile(f, fakeDistRoot, newConfig) - const expected = expectedValMap[fileName] - expect(response).toStrictEqual(expected) + const expectedValMap = { + 'index.html': { 'adp-testHeader': 'generic-header' }, + 'test.js': { 'adp-testHeader': 'generic-header' } + } + expectedValMap[folderPath1] = { 'adp-testHeader': 'all-files-in-css-folder-header' } + expectedValMap[folderPath2] = { 'adp-testHeader': 'all-files-in-js-folder-header' } + expectedValMap[folderPath3] = { 'adp-testHeader': 'all-files-in-images-folder-header' } + expectedValMap[folderPath4] = { 'adp-testHeader': 'all-files-in-images-folder-header' } + + files.forEach(f => { + const fileName = f.replace(path.join(fakeDistRoot, path.sep), '') + const response = rs.getResponseHeadersForFile(f, fakeDistRoot, newConfig) + const expected = expectedValMap[fileName] + expect(response).toStrictEqual(expected) + }) }) - }) - test('get response header with invalid header name', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const newConfig = global.configWithModifiedWeb(global.fakeConfig, { - 'response-headers': { - '/*': { - 無効な名前: 'generic-header' + test('get response header for specific file based path rules', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const folderPath1 = 'css' + path.sep + 'ui.css' + const folderPath2 = 'scripts' + path.sep + 'test.js' + const folderPath3 = 'images' + path.sep + 'image.png' + await global.addFakeFiles(vol, 'fakeDir', ['index.html', 'test.js', folderPath1, folderPath2, folderPath3]) + const files = await rs.walkDir('fakeDir') + const fakeDistRoot = path.parse(files[0]).dir + + const newConfig = global.configWithModifiedWeb(global.fakeConfig, { + 'response-headers': { + '/*': { + testHeader: 'generic-header' + }, + '/css/*': { + testHeader: 'all-files-in-css-folder-header' + }, + '/css/ui.css': { + testHeader: 'specific-css-file-header' + }, + '/scripts/*': { + testHeader: 'all-files-in-js-folder-header' + }, + '/scripts/test.js': { + testHeader: 'specific-js-file-header' + }, + '/images/*': { + testHeader: 'all-files-in-images-folder-header' + }, + '/images/image.png': { + testHeader: 'specific-image-file-header' + } } + }) + + const expectedValMap = { + 'index.html': { 'adp-testHeader': 'generic-header' }, + 'test.js': { 'adp-testHeader': 'generic-header' } } + expectedValMap[folderPath1] = { 'adp-testHeader': 'specific-css-file-header' } + expectedValMap[folderPath2] = { 'adp-testHeader': 'specific-js-file-header' } + expectedValMap[folderPath3] = { 'adp-testHeader': 'specific-image-file-header' } + + files.forEach(f => { + const fileName = f.replace(path.join(fakeDistRoot, path.sep), '') + const response = rs.getResponseHeadersForFile(f, fakeDistRoot, newConfig) + const expected = expectedValMap[fileName] + expect(response).toStrictEqual(expected) + }) }) - const fakeDistRoot = '/fake/web-prod/' - expect(() => rs.getResponseHeadersForFile(fakeDistRoot + 'index.html', fakeDistRoot, newConfig)).toThrowWithMessageContaining( - '[WebLib:ERROR_INVALID_HEADER_NAME] `無効な名前` is not a valid response header name') - }) - - test('get response header with invalid header value', async () => { - const rs = new RemoteStorage(global.fakeTVMResponse) - const newConfig = global.configWithModifiedWeb(global.fakeConfig, { - 'response-headers': { - '/*': { - testHeader: '無効な値' + test('get response header for file extension based path rules', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const folderPath1 = 'css' + path.sep + 'ui.css' + const folderPath2 = 'scripts' + path.sep + 'test.js' + const folderPath3 = 'images' + path.sep + 'image.png' + await global.addFakeFiles(vol, 'fakeDir', ['index.html', 'test.js', folderPath1, folderPath2, folderPath3]) + const files = await rs.walkDir('fakeDir') + const fakeDistRoot = path.parse(files[0]).dir + + const newConfig = global.configWithModifiedWeb(global.fakeConfig, { + 'response-headers': { + '/*': { + testHeader: 'generic-header' + }, + '/*.css': { + testHeader: 'all-css-files-header' + }, + '/*.js': { + testHeader: 'all-js-files-header' + }, + '/*.png': { + testHeader: 'all-png-files-header' + } } + }) + + const expectedValMap = { + 'index.html': { 'adp-testHeader': 'generic-header' }, + 'test.js': { 'adp-testHeader': 'all-js-files-header' } } + expectedValMap[folderPath1] = { 'adp-testHeader': 'all-css-files-header' } + expectedValMap[folderPath2] = { 'adp-testHeader': 'all-js-files-header' } + expectedValMap[folderPath3] = { 'adp-testHeader': 'all-png-files-header' } + + files.forEach(f => { + const fileName = f.replace(path.join(fakeDistRoot, path.sep), '') + const response = rs.getResponseHeadersForFile(f, fakeDistRoot, newConfig) + const expected = expectedValMap[fileName] + expect(response).toStrictEqual(expected) + }) }) - const fakeDistRoot = '/fake/web-prod/' - expect(() => rs.getResponseHeadersForFile(fakeDistRoot + 'index.html', fakeDistRoot, newConfig)).toThrowWithMessageContaining( - '[WebLib:ERROR_INVALID_HEADER_VALUE] `無効な値` is not a valid response header value for `testHeader`') - }) + test('get response header with invalid header name', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const newConfig = global.configWithModifiedWeb(global.fakeConfig, { + 'response-headers': { + '/*': { + 無効な名前: 'generic-header' + } + } + }) - test('Metadata check for response headers', async () => { - global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) - const rs = new RemoteStorage(global.fakeTVMResponse) - const files = await rs.walkDir('fakeDir') - const fakeDistRoot = files[0].substring(0, files[0].indexOf('index.js')) - const newConfig = global.configWithModifiedWeb(global.fakeConfig, { - 'response-headers': { - '/*': { - testHeader: 'generic-header' + const fakeDistRoot = '/fake/web-prod/' + expect(() => rs.getResponseHeadersForFile(fakeDistRoot + 'index.html', fakeDistRoot, newConfig)).toThrowWithMessageContaining( + '[WebLib:ERROR_INVALID_HEADER_NAME] `無効な名前` is not a valid response header name') + }) + + test('get response header with invalid header value', async () => { + const rs = new RemoteStorage(global.fakeAuthToken) + const newConfig = global.configWithModifiedWeb(global.fakeConfig, { + 'response-headers': { + '/*': { + testHeader: '無効な値' + } } - } + }) + + const fakeDistRoot = '/fake/web-prod/' + expect(() => rs.getResponseHeadersForFile(fakeDistRoot + 'index.html', fakeDistRoot, newConfig)).toThrowWithMessageContaining( + '[WebLib:ERROR_INVALID_HEADER_VALUE] `無効な値` is not a valid response header value for `testHeader`') }) - // const fakeConfig = {} - await rs.uploadFile('fakeDir/index.js', 'fakeprefix', newConfig, fakeDistRoot) - const body = Buffer.from('fake content', 'utf8') - const expected = { - Bucket: 'fake-bucket', - Key: 'fakeprefix/index.js', - Body: body, - ContentType: 'application/javascript', - Metadata: { - 'adp-testHeader': 'generic-header' - } - } - expect(mockS3.putObject).toHaveBeenCalledWith(expect.objectContaining(expected)) }) - test('Cache control override from response headers', async () => { - global.addFakeFiles(vol, 'fakeDir', { 'index.html': 'fake content' }) - const rs = new RemoteStorage(global.fakeTVMResponse) - const files = await rs.walkDir('fakeDir') - const fakeDistRoot = path.parse(files[0]).dir - const filePath = files[0] // Use absolute path from walkDir - const newConfig = global.configWithModifiedWeb(global.fakeConfig, { - 'response-headers': { - '/*.html': { - 'cache-control': 'max-age=3600, s-maxage=7200', - testHeader: 'generic-header' + describe('uploadFile with response headers', () => { + test('includes response headers in upload request', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const rs = new RemoteStorage(global.fakeAuthToken) + const files = await rs.walkDir('fakeDir') + const fakeDistRoot = files[0].substring(0, files[0].indexOf('index.js')) + const newConfig = global.configWithModifiedWeb(createAppConfig(), { + 'response-headers': { + '/*': { + testHeader: 'generic-header' + } } - } + }) + + await rs.uploadFile('fakeDir/index.js', 'fakeprefix', newConfig, fakeDistRoot, global.fakeAuthToken) + + const callArgs = global.fetch.mock.calls[0] + const body = JSON.parse(callArgs[1].body) + expect(body.file.customHeaders).toMatchObject({ + 'adp-testHeader': 'generic-header' + }) }) - await rs.uploadFile(filePath, 'fakeprefix', newConfig, fakeDistRoot) - const body = Buffer.from('fake content', 'utf8') - const expected = { - Bucket: 'fake-bucket', - Key: 'fakeprefix/index.html', - Body: body, - ContentType: 'text/html', - CacheControl: 'max-age=3600, s-maxage=7200', - Metadata: { + + test('cache control override from response headers', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.html': 'fake content' }) + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const rs = new RemoteStorage(global.fakeAuthToken) + const files = await rs.walkDir('fakeDir') + const fakeDistRoot = path.parse(files[0]).dir + const filePath = files[0] + const newConfig = global.configWithModifiedWeb(createAppConfig(), { + 'response-headers': { + '/*.html': { + 'cache-control': 'max-age=3600, s-maxage=7200', + testHeader: 'generic-header' + } + } + }) + + await rs.uploadFile(filePath, 'fakeprefix', newConfig, fakeDistRoot, global.fakeAuthToken) + + const callArgs = global.fetch.mock.calls[0] + const body = JSON.parse(callArgs[1].body) + // cache-control from response-headers should override the computed cacheControl + expect(body.file.cacheControl).toBe('max-age=3600, s-maxage=7200') + // adp-cache-control should be removed from customHeaders + expect(body.file.customHeaders).not.toHaveProperty('adp-cache-control') + expect(body.file.customHeaders).toMatchObject({ 'adp-testHeader': 'generic-header' - } - } - expect(mockS3.putObject).toHaveBeenCalledWith(expect.objectContaining(expected)) - // Verify that adp-cache-control was removed from metadata - const putObjectCall = mockS3.putObject.mock.calls[0][0] - expect(putObjectCall.Metadata).not.toHaveProperty('adp-cache-control') - }) + }) + }) - test('uploadFile includes auditUserId in metadata when set', async () => { - global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) - const rs = new RemoteStorage(global.fakeTVMResponse) - const fakeConfig = { ...global.fakeConfig, auditUserId: 'test-user-123' } - await rs.uploadFile('fakeDir/index.js', 'fakeprefix', fakeConfig, 'fakeDir') - const body = Buffer.from('fake content', 'utf8') - const expected = { - Bucket: 'fake-bucket', - Key: 'fakeprefix/index.js', - Body: body, - ContentType: 'application/javascript' - } - expect(mockS3.putObject).toHaveBeenCalledWith(expect.objectContaining(expected)) - }) + test('does not set customHeaders when responseHeaders is empty', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + delete appConfig.web // No web.response-headers - test('uploadFile does not set Metadata when responseHeaders is empty', async () => { - global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) - const rs = new RemoteStorage(global.fakeTVMResponse) - const fakeConfig = { - app: global.fakeConfig.app - // No web.response-headers - } - await rs.uploadFile('fakeDir/index.js', 'fakeprefix', fakeConfig, 'fakeDir') - const body = Buffer.from('fake content', 'utf8') - const putObjectCall = mockS3.putObject.mock.calls[0][0] - expect(putObjectCall).not.toHaveProperty('Metadata') - expect(putObjectCall).toMatchObject({ - Bucket: 'fake-bucket', - Key: 'fakeprefix/index.js', - Body: body, - ContentType: 'application/javascript' + await rs.uploadFile('fakeDir/index.js', 'fakeprefix', appConfig, 'fakeDir', global.fakeAuthToken) + + const callArgs = global.fetch.mock.calls[0] + const body = JSON.parse(callArgs[1].body) + // When no response-headers config, getResponseHeadersForFile returns undefined + // which becomes {} after the ?? {} fallback + expect(body.file.customHeaders).toEqual({}) }) - }) - test('uploadFile sets CacheControl even when responseHeaders is empty', async () => { - global.addFakeFiles(vol, 'fakeDir', { 'index.html': 'fake content' }) - const rs = new RemoteStorage(global.fakeTVMResponse) - const fakeConfig = { - app: global.fakeConfig.app - // No web.response-headers - } - await rs.uploadFile('fakeDir/index.html', 'fakeprefix', fakeConfig, 'fakeDir') - const body = Buffer.from('fake content', 'utf8') - const putObjectCall = mockS3.putObject.mock.calls[0][0] - expect(putObjectCall).not.toHaveProperty('Metadata') - expect(putObjectCall).toMatchObject({ - Bucket: 'fake-bucket', - Key: 'fakeprefix/index.html', - Body: body, - ContentType: 'text/html', - CacheControl: 's-maxage=60, max-age=60' + test('sets cacheControl even when responseHeaders is empty', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.html': 'fake content' }) + global.fetch.mockResolvedValue(mockResponse({ success: true })) + const rs = new RemoteStorage(global.fakeAuthToken) + const appConfig = createAppConfig() + delete appConfig.web // No web.response-headers + + await rs.uploadFile('fakeDir/index.html', 'fakeprefix', appConfig, 'fakeDir', global.fakeAuthToken) + + const callArgs = global.fetch.mock.calls[0] + const body = JSON.parse(callArgs[1].body) + expect(body.file.cacheControl).toBe('s-maxage=60, max-age=60') + expect(body.file.customHeaders).toEqual({}) }) }) }) diff --git a/test/src/deploy-web.test.js b/test/src/deploy-web.test.js index a909adc..7053707 100644 --- a/test/src/deploy-web.test.js +++ b/test/src/deploy-web.test.js @@ -15,13 +15,7 @@ const deployWeb = require('../../src/deploy-web') const fs = require('fs-extra') jest.mock('fs-extra') -jest.mock('../../lib/getS3Creds') -const getS3Credentials = require('../../lib/getS3Creds') -getS3Credentials.mockResolvedValue('fakecreds') - const mockRemoteStorageInstance = { - emptyFolder: jest.fn(), - folderExists: jest.fn(), uploadDir: jest.fn() } const RemoteStorage = require('../../lib/remote-storage') @@ -34,10 +28,7 @@ jest.mock('../../lib/remote-storage', () => { describe('deploy-web', () => { beforeEach(() => { RemoteStorage.mockClear() - mockRemoteStorageInstance.emptyFolder.mockReset() - mockRemoteStorageInstance.folderExists.mockReset() mockRemoteStorageInstance.uploadDir.mockReset() - getS3Credentials.mockClear() global.cleanFs(vol) }) @@ -48,25 +39,34 @@ describe('deploy-web', () => { await expect(deployWeb({ app: { hasFrontEnd: false } })).rejects.toThrow('cannot deploy web') }) - test('throws if src dir does not exist', async () => { + test('throws if no auth token', async () => { const config = { - s3: { - creds: 'not-null' - }, app: { hasFrontend: true }, + ow: { + namespace: 'ns', + auth_handler: { + getAuthHeader: jest.fn().mockResolvedValue(null) + } + }, web: { distProd: 'dist' } } - await expect(deployWeb(config)).rejects.toThrow('missing files in dist') + await expect(deployWeb(config)).rejects.toThrow('cannot deploy web, Authorization is required') }) - test('throws if src dir is not a directory', async () => { + test('throws if src dir does not exist', async () => { const config = { s3: { - creds: 'not-null' + folder: 'somefolder' + }, + ow: { + namespace: 'ns', + auth_handler: { + getAuthHeader: jest.fn().mockResolvedValue('Bearer token') + } }, app: { hasFrontend: true @@ -75,105 +75,45 @@ describe('deploy-web', () => { distProd: 'dist' } } - fs.existsSync.mockReturnValue(true) - fs.lstatSync.mockReturnValue({ isDirectory: () => false }) await expect(deployWeb(config)).rejects.toThrow('missing files in dist') }) - test('throws if src dir is empty', async () => { + test('throws if src dir is not a directory', async () => { const config = { s3: { - creds: 'not-null' - }, - app: { - hasFrontend: true + folder: 'somefolder' }, - web: { - distProd: 'dist' - } - } - fs.existsSync.mockReturnValue(true) - fs.lstatSync.mockReturnValue({ isDirectory: () => true }) - fs.readdirSync.mockReturnValue({ length: 0 }) - await expect(deployWeb(config)).rejects.toThrow('missing files in dist') - }) - - test('uploads files', async () => { - const config = { ow: { namespace: 'ns', - auth: 'password' - }, - s3: { - credsCacheFile: 'file', - tvmUrl: 'url', - folder: 'somefolder' + auth_handler: { + getAuthHeader: jest.fn().mockResolvedValue('Bearer token') + } }, app: { - hasFrontend: true, - hostname: 'host' + hasFrontend: true }, web: { distProd: 'dist' } } fs.existsSync.mockReturnValue(true) - fs.lstatSync.mockReturnValue({ isDirectory: () => true }) - fs.readdirSync.mockReturnValue({ length: 1 }) - await expect(deployWeb(config)).resolves.toEqual('https://ns.host/index.html') - expect(getS3Credentials).toHaveBeenCalledWith(config) - expect(RemoteStorage).toHaveBeenCalledWith('fakecreds') - expect(mockRemoteStorageInstance.uploadDir).toHaveBeenCalledWith('dist', 'somefolder', config, null) - expect(mockRemoteStorageInstance.emptyFolder).not.toHaveBeenCalled() - expect(mockRemoteStorageInstance.folderExists).toHaveBeenCalledWith('somefolder/') + fs.lstatSync.mockReturnValue({ isDirectory: () => false }) + await expect(deployWeb(config)).rejects.toThrow('missing files in dist') }) - test('uploads files with log func', async () => { + test('throws if src dir is empty', async () => { const config = { - ow: { - namespace: 'ns', - auth: 'password' - }, s3: { - credsCacheFile: 'file', - tvmUrl: 'url', folder: 'somefolder' }, - app: { - hasFrontend: true, - hostname: 'host' - }, - web: { - distProd: 'dist' - } - } - fs.existsSync.mockReturnValue(true) - fs.lstatSync.mockReturnValue({ isDirectory: () => true }) - fs.readdirSync.mockReturnValue({ length: 1 }) - const mockLogger = jest.fn() - // for func coverage - mockRemoteStorageInstance.uploadDir.mockImplementation((a, b, c, func) => func('somefile')) - await expect(deployWeb(config, mockLogger)).resolves.toEqual('https://ns.host/index.html') - expect(getS3Credentials).toHaveBeenCalledWith(config) - expect(RemoteStorage).toHaveBeenCalledWith('fakecreds') - expect(mockRemoteStorageInstance.uploadDir).toHaveBeenCalledWith('dist', 'somefolder', config, expect.any(Function)) - expect(mockRemoteStorageInstance.emptyFolder).not.toHaveBeenCalled() - expect(mockRemoteStorageInstance.folderExists).toHaveBeenCalledWith('somefolder/') - }) - - test('overwrites remote files', async () => { - const config = { ow: { namespace: 'ns', - auth: 'password' - }, - s3: { - creds: 'somecreds', - folder: 'somefolder' + auth_handler: { + getAuthHeader: jest.fn().mockResolvedValue('Bearer token') + } }, app: { - hasFrontend: true, - hostname: 'host' + hasFrontend: true }, web: { distProd: 'dist' @@ -181,26 +121,17 @@ describe('deploy-web', () => { } fs.existsSync.mockReturnValue(true) fs.lstatSync.mockReturnValue({ isDirectory: () => true }) - const mockLogger = jest.fn() - fs.readdirSync.mockReturnValue({ length: 1 }) - - mockRemoteStorageInstance.folderExists.mockResolvedValue(true) - - await expect(deployWeb(config, mockLogger)).resolves.toEqual('https://ns.host/index.html') - expect(getS3Credentials).toHaveBeenCalledWith(config) - expect(mockLogger).toHaveBeenCalledWith('warning: an existing deployment will be overwritten') - expect(RemoteStorage).toHaveBeenCalledWith('fakecreds') - expect(mockRemoteStorageInstance.folderExists).toHaveBeenCalledWith('somefolder/') - expect(mockRemoteStorageInstance.uploadDir).toHaveBeenCalledWith('dist', 'somefolder', config, expect.any(Function)) - // empty dir! - expect(mockRemoteStorageInstance.emptyFolder).toHaveBeenCalledWith('somefolder/') + fs.readdirSync.mockReturnValue({ length: 0 }) + await expect(deployWeb(config)).rejects.toThrow('missing files in dist') }) - test('overwrites remote files no log func', async () => { + test('uploads files', async () => { const config = { ow: { namespace: 'ns', - auth: 'password' + auth_handler: { + getAuthHeader: jest.fn().mockResolvedValue('Bearer token') + } }, s3: { folder: 'somefolder' @@ -216,27 +147,21 @@ describe('deploy-web', () => { fs.existsSync.mockReturnValue(true) fs.lstatSync.mockReturnValue({ isDirectory: () => true }) fs.readdirSync.mockReturnValue({ length: 1 }) - - mockRemoteStorageInstance.folderExists.mockResolvedValue(true) - await expect(deployWeb(config)).resolves.toEqual('https://ns.host/index.html') - expect(getS3Credentials).toHaveBeenCalledWith(config) - expect(mockRemoteStorageInstance.folderExists).toHaveBeenCalledWith('somefolder/') + expect(RemoteStorage).toHaveBeenCalledWith('Bearer token') expect(mockRemoteStorageInstance.uploadDir).toHaveBeenCalledWith('dist', 'somefolder', config, null) - // empty dir! - expect(mockRemoteStorageInstance.emptyFolder).toHaveBeenCalledWith('somefolder/') }) - test('calls to s3 should use ending slash', async () => { + test('uploads files with log func', async () => { const config = { ow: { namespace: 'ns', - auth: 'password' + auth_handler: { + getAuthHeader: jest.fn().mockResolvedValue('Bearer token') + } }, s3: { - credsCacheFile: 'file', - tvmUrl: 'url', - folder: 'nsfolder' + folder: 'somefolder' }, app: { hasFrontend: true, @@ -246,23 +171,15 @@ describe('deploy-web', () => { distProd: 'dist' } } - const emptyFolder = jest.fn() - const folderExists = jest.fn(() => true) - RemoteStorage.mockImplementation(() => { - return { - emptyFolder, - folderExists, - uploadDir: jest.fn() - } - }) fs.existsSync.mockReturnValue(true) fs.lstatSync.mockReturnValue({ isDirectory: () => true }) - const mockLogger = jest.fn() fs.readdirSync.mockReturnValue({ length: 1 }) + const mockLogger = jest.fn() + // for func coverage + mockRemoteStorageInstance.uploadDir.mockImplementation((a, b, c, func) => func('dist/somefile')) await expect(deployWeb(config, mockLogger)).resolves.toEqual('https://ns.host/index.html') - expect(getS3Credentials).toHaveBeenCalled() - expect(RemoteStorage).toHaveBeenCalledTimes(1) - expect(folderExists).toHaveBeenLastCalledWith('nsfolder/') - expect(emptyFolder).toHaveBeenLastCalledWith('nsfolder/') + expect(RemoteStorage).toHaveBeenCalledWith('Bearer token') + expect(mockRemoteStorageInstance.uploadDir).toHaveBeenCalledWith('dist', 'somefolder', config, expect.any(Function)) + expect(mockLogger).toHaveBeenCalledWith('deploying somefile') }) }) diff --git a/test/src/undeploy-web.test.js b/test/src/undeploy-web.test.js index a0847d7..49170cd 100644 --- a/test/src/undeploy-web.test.js +++ b/test/src/undeploy-web.test.js @@ -12,10 +12,6 @@ governing permissions and limitations under the License. const undeployWeb = require('../../src/undeploy-web') -jest.mock('../../lib/getS3Creds') -const getS3Credentials = require('../../lib/getS3Creds') -getS3Credentials.mockResolvedValue('fakecreds') - const mockRemoteStorageInstance = { emptyFolder: jest.fn(), folderExists: jest.fn() @@ -32,7 +28,6 @@ describe('undeploy-web', () => { RemoteStorage.mockClear() mockRemoteStorageInstance.emptyFolder.mockReset() mockRemoteStorageInstance.folderExists.mockReset() - getS3Credentials.mockClear() }) test('throws if config does not have an app, or frontEnd', async () => { @@ -41,15 +36,30 @@ describe('undeploy-web', () => { await expect(undeployWeb({ app: { hasFrontEnd: false } })).rejects.toThrow('cannot undeploy web') }) - test('calls getS3Credentials and empties folder', async () => { + test('throws if no auth token', async () => { + const config = { + ow: { + namespace: 'ns', + auth_handler: { + getAuthHeader: jest.fn().mockResolvedValue(null) + } + }, + app: { + hasFrontend: true + } + } + await expect(undeployWeb(config)).rejects.toThrow('cannot undeploy web, Authorization is required') + }) + + test('calls folderExists and empties folder', async () => { const config = { ow: { namespace: 'ns', - auth: 'password' + auth_handler: { + getAuthHeader: jest.fn().mockResolvedValue('Bearer token') + } }, s3: { - creds: 'fakes3creds', - tvmUrl: 'url', folder: 'somefolder' }, app: { @@ -62,21 +72,20 @@ describe('undeploy-web', () => { } mockRemoteStorageInstance.folderExists.mockResolvedValue(true) await undeployWeb(config) - expect(getS3Credentials).toHaveBeenCalledWith(config) - expect(RemoteStorage).toHaveBeenCalledWith('fakecreds') - expect(mockRemoteStorageInstance.folderExists).toHaveBeenCalledWith('somefolder/') - expect(mockRemoteStorageInstance.emptyFolder).toHaveBeenCalledWith('somefolder/') + expect(RemoteStorage).toHaveBeenCalledWith('Bearer token') + expect(mockRemoteStorageInstance.folderExists).toHaveBeenCalledWith('/', config) + expect(mockRemoteStorageInstance.emptyFolder).toHaveBeenCalledWith('/', config) }) test('throws if remoteStorage folder does not exist', async () => { const config = { ow: { namespace: 'ns', - auth: 'password' + auth_handler: { + getAuthHeader: jest.fn().mockResolvedValue('Bearer token') + } }, s3: { - credsCacheFile: 'file', - tvmUrl: 'url', folder: 'somefolder' }, app: { @@ -86,10 +95,10 @@ describe('undeploy-web', () => { distProd: 'dist' } } + mockRemoteStorageInstance.folderExists.mockResolvedValue(false) await expect(undeployWeb(config)).rejects.toThrow('cannot undeploy static files') - expect(getS3Credentials).toHaveBeenCalledWith(config) - expect(RemoteStorage).toHaveBeenCalledWith('fakecreds') - expect(mockRemoteStorageInstance.folderExists).toHaveBeenCalledWith('somefolder/') + expect(RemoteStorage).toHaveBeenCalledWith('Bearer token') + expect(mockRemoteStorageInstance.folderExists).toHaveBeenCalledWith('/', config) expect(mockRemoteStorageInstance.emptyFolder).not.toHaveBeenCalled() }) }) From 6ccfa8ecf468d0f124e5a57fa73bc4f5c038c8a6 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Tue, 20 Jan 2026 00:15:54 -0800 Subject: [PATCH 08/10] remove problematic #class-prop syntax --- lib/remote-storage.js | 62 ++++++++++++++++--------------------------- 1 file changed, 23 insertions(+), 39 deletions(-) diff --git a/lib/remote-storage.js b/lib/remote-storage.js index a3d71a2..b2b0b38 100644 --- a/lib/remote-storage.js +++ b/lib/remote-storage.js @@ -13,42 +13,28 @@ governing permissions and limitations under the License. const path = require('path') const mime = require('mime-types') const fs = require('fs-extra') -const joi = require('joi') const klaw = require('klaw') const http = require('http') -const { NodeHttpHandler } = require('@smithy/node-http-handler') -const { ProxyAgent } = require('proxy-agent') +// const { NodeHttpHandler } = require('@smithy/node-http-handler') +// const { ProxyAgent } = require('proxy-agent') const { codes, logAndThrow } = require('./StorageError') -const { getCliEnv, PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') - +const { getCliEnv, PROD_ENV } = require('@adobe/aio-lib-env') // or https://deploy-service.dev.app-builder.adp.adobe.io // or http://localhost:3000 -const deploymentServiceUrl = getCliEnv() === PROD_ENV +const deploymentServiceUrl = getCliEnv() === PROD_ENV ? 'https://deploy-service.app-builder.adp.adobe.io' : 'https://deploy-service.stg.app-builder.corp.adp.adobe.io' const fileExtensionPattern = /\*\.[0-9a-zA-Z]+$/ -// todo: read stage/prod from config and generate the url dynamically -// allow .env setting for the url, for localhost, and/or stage->dev.adobeio-static.net - module.exports = class RemoteStorage { - - /* -curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-stage/files/' \ - --request DELETE \ - -H 'authorization: Bearer ...' -*/ - - #authToken; - /** * Constructor for RemoteStorage * @param {string} authToken - The authorization token to use for the remote storage */ - constructor(authToken) { - this.#authToken = authToken + constructor (authToken) { + this._authToken = authToken } /** @@ -61,14 +47,14 @@ curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-s if (typeof prefix !== 'string') { throw new Error('prefix must be a valid string') } - if (!this.#authToken) { + if (!this._authToken) { throw new Error('cannot check if folder exists, Authorization is required') } // Call the list files endpoint (GET /files) - there is no GET /files/:key route const response = await fetch(`${deploymentServiceUrl}/cdn-api/namespaces/${appConfig.ow.namespace}/files`, { method: 'GET', headers: { - 'Authorization': this.#authToken + Authorization: this._authToken } }) if (!response.ok) { @@ -79,12 +65,6 @@ curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-s return Array.isArray(files) && files.length > 0 } - /* -curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-stage/files/test.txt' \ - --request DELETE \ - -H 'authorization: Bearer ...' -*/ - /** * Empties all files for the namespace or deletes a specific file * @param {string} prefix - '/' to delete all files, or a specific file path @@ -95,7 +75,7 @@ curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-s if (typeof prefix !== 'string') { throw new Error('prefix must be a valid string') } - if (!this.#authToken) { + if (!this._authToken) { throw new Error('cannot empty folder, Authorization is required') } // Server route is DELETE /files/:key @@ -105,17 +85,15 @@ curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-s ? `${deploymentServiceUrl}/cdn-api/namespaces/${appConfig.ow.namespace}/files/` : `${deploymentServiceUrl}/cdn-api/namespaces/${appConfig.ow.namespace}/files/${prefix}` - console.log('url is', url) const response = await fetch(url, { method: 'DELETE', headers: { - 'Authorization': this.#authToken + Authorization: this._authToken } }) return response.ok } - /** * Uploads a file to the CDN API * @param {string} file - Full local file path @@ -130,6 +108,7 @@ curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-s } const url = `${deploymentServiceUrl}/cdn-api/namespaces/${appConfig.ow.namespace}/files` + const content = await fs.readFile(file) const mimeType = mime.lookup(path.extname(file)) // first we will grab it from the global config: htmlCacheDuration, etc. @@ -148,7 +127,11 @@ curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-s // file.name is the path relative to namespace (e.g., 'images/photo.jpg' or 'index.html') // The server will prepend the namespace to create the S3 key: ${namespace}/${file.name} const fileName = path.basename(file) - const filePathForServer = filePath === '' ? fileName : `${filePath}/${fileName}` + let relativeFilePath = filePath.replace(appConfig.ow.namespace, '') + if (relativeFilePath.startsWith('/')) { + relativeFilePath = relativeFilePath.substring(1) + } + const filePathForServer = relativeFilePath === '' ? fileName : `${relativeFilePath}/${fileName}` const data = { file: { contentType: mimeType, @@ -163,7 +146,7 @@ curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-s body: JSON.stringify(data), headers: { 'Content-Type': 'application/json', - 'Authorization': this.#authToken + Authorization: this._authToken } }).catch(error => { console.error('Error uploading file:', file) @@ -269,7 +252,7 @@ curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-s if (typeof basePath !== 'string') { throw new Error('basePath must be a valid string') } - + // walk the whole directory recursively using klaw. const files = await this.walkDir(dir) @@ -280,7 +263,7 @@ curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-s const batchSize = 50 let fileBatch = files.splice(0, batchSize) const allResults = [] - if (!this.#authToken) { + if (!this._authToken) { throw new Error('cannot upload files, Authorization is required') } while (fileBatch.length > 0) { @@ -292,14 +275,14 @@ curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-s let relativeDir = path.dirname(path.relative(dir, file)) // path.relative returns '.' for files in the root directory, normalize to empty string relativeDir = relativeDir === '.' ? '' : relativeDir - + // Combine basePath with relativeDir to get the full file path relative to namespace // e.g., basePath='' + relativeDir='images' = 'images' // basePath='assets' + relativeDir='images' = 'assets/images' const filePath = this._urlJoin(basePath, relativeDir) - + // Upload file with the calculated filePath (server will prepend namespace) - const s3Result = await this.uploadFile(file, filePath, appConfig, dir, this.#authToken) + const s3Result = await this.uploadFile(file, filePath, appConfig, dir, this._authToken) if (postFileUploadCallback) { postFileUploadCallback(file) } @@ -310,6 +293,7 @@ curl 'http://localhost:3000/cdn-api/namespaces/development-918-cdntestappgreen-s } return allResults } + /** * Joins url path parts using URL() methods * @param {...string} args url parts From d371fb068ad7c489d25cd166474a721b71f84295 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Tue, 20 Jan 2026 00:16:18 -0800 Subject: [PATCH 09/10] tests passing, env mocking --- src/deploy-web.js | 1 + test/lib/remote-storage.test.js | 58 +++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/deploy-web.js b/src/deploy-web.js index 3c2e8c8..28c4b77 100644 --- a/src/deploy-web.js +++ b/src/deploy-web.js @@ -36,6 +36,7 @@ const deployWeb = async (config, log) => { const remoteStorage = new RemoteStorage(bearerToken) + console.log('config.s3.folder is', config.s3.folder) const _log = log ? (f) => log(`deploying ${path.relative(dist, f)}`) : null await remoteStorage.uploadDir(dist, config.s3.folder, config, _log) diff --git a/test/lib/remote-storage.test.js b/test/lib/remote-storage.test.js index 467759e..d59e83b 100644 --- a/test/lib/remote-storage.test.js +++ b/test/lib/remote-storage.test.js @@ -264,7 +264,7 @@ describe('RemoteStorage', () => { expect(body.file.content).toBeDefined() // base64 encoded content }) - test('should call PUT /files with slash-prefix', async () => { + test('should call PUT /files without slash-prefix', async () => { global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) global.fetch.mockResolvedValue(mockResponse({ success: true })) const rs = new RemoteStorage(global.fakeAuthToken) @@ -274,7 +274,7 @@ describe('RemoteStorage', () => { const callArgs = global.fetch.mock.calls[0] const body = JSON.parse(callArgs[1].body) - expect(body.file.name).toBe('/slash-prefix/index.js') + expect(body.file.name).toBe('slash-prefix/index.js') }) test('should handle unknown Content-Type', async () => { @@ -747,3 +747,57 @@ describe('RemoteStorage', () => { }) }) }) + +describe('RemoteStorage environment URL selection', () => { + // The deploymentServiceUrl is computed at module load time, so we need to + // reset modules and set up mocks BEFORE requiring remote-storage + + beforeEach(() => { + jest.resetModules() + global.fetch.mockReset() + }) + + test('uses stage url when in stage environment', async () => { + // Set up mock BEFORE requiring the module + jest.doMock('@adobe/aio-lib-env', () => ({ + getCliEnv: jest.fn(() => 'stage'), + PROD_ENV: 'prod', + STAGE_ENV: 'stage' + })) + + // Now require the module fresh with the mock in place + const RemoteStorageFresh = require('../../lib/remote-storage') + + global.fetch.mockResolvedValue(mockResponse([])) + const rs = new RemoteStorageFresh(global.fakeAuthToken) + + await rs.folderExists('fakeprefix', createAppConfig()) + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('https://deploy-service.stg.app-builder.corp.adp.adobe.io'), + expect.any(Object) + ) + }) + + test('uses prod url when in prod environment', async () => { + // Set up mock for prod environment + jest.doMock('@adobe/aio-lib-env', () => ({ + getCliEnv: jest.fn(() => 'prod'), + PROD_ENV: 'prod', + STAGE_ENV: 'stage' + })) + + // Now require the module fresh with the mock in place + const RemoteStorageFresh = require('../../lib/remote-storage') + + global.fetch.mockResolvedValue(mockResponse([])) + const rs = new RemoteStorageFresh(global.fakeAuthToken) + + await rs.folderExists('fakeprefix', createAppConfig()) + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('https://deploy-service.app-builder.adp.adobe.io'), + expect.any(Object) + ) + }) +}) From 5a18204112ed034e70f831d54c2a92466a613e9c Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 22 Jan 2026 19:42:42 -0800 Subject: [PATCH 10/10] clean up --- src/deploy-web.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/deploy-web.js b/src/deploy-web.js index 28c4b77..20a0215 100644 --- a/src/deploy-web.js +++ b/src/deploy-web.js @@ -35,8 +35,6 @@ const deployWeb = async (config, log) => { } const remoteStorage = new RemoteStorage(bearerToken) - - console.log('config.s3.folder is', config.s3.folder) const _log = log ? (f) => log(`deploying ${path.relative(dist, f)}`) : null await remoteStorage.uploadDir(dist, config.s3.folder, config, _log)