diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cb24de4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = true +indent_style = tab +indent_size = 1 diff --git a/README.md b/README.md index 2103cf8..4c3e154 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ This adapter is designed to replace the existing `AzureFile` field in KeystoneJS This adapter uses azure's blob store, not its file store. You will have to create a blob store account and container in the [azure portal](https://portal.azure.com/) before you can use this adapter to store things. See [azure documentation](https://azure.microsoft.com/en-us/documentation/articles/storage-create-storage-account/) for details on getting started with azure blob stores. +This adapter also supports CDN's by way of setting a custom domain and allowing automated purges upon +uploading or removing a file from the blob. + Compatible with Node.js 0.12+ ## Usage @@ -12,18 +15,34 @@ Configure the storage adapter: ```js var storage = new keystone.Storage({ - adapter: require('keystone-storage-adapter-azure'), - azure: { - accountName: 'myaccount', // required; defaults to env.AZURE_STORAGE_ACCOUNT - accountKey: 'secret', // required; defaults to env.AZURE_STORAGE_ACCESS_KEY - container: 'mycontainer', // required; defaults to env.AZURE_STORAGE_CONTAINER - generateFilename: keystone.Storage.randomFilename, // default - }, - schema: { - container: true, // optional; store the referenced container in the database - etag: true, // optional; store the etag for the resource - url: true, // optional; generate & store a public URL - }, + adapter: require('keystone-storage-adapter-azure'), + azure: { + accountName: 'myaccount', // required; defaults to env.AZURE_STORAGE_ACCOUNT + accountKey: 'secret', // required; defaults to env.AZURE_STORAGE_ACCESS_KEY + container: 'mycontainer', // required; defaults to env.AZURE_STORAGE_CONTAINER + generateFilename: keystone.Storage.randomFilename, // default + cacheControl: '', // optional; defaults to env.AZURE_STORAGE_CACHE_CONTROL + cdn: { // optional; + customDomain: '', // optional; env.AZURE_CDN_CUSTOM_DOMAIN, + purge: false, // optional; defaults to env.AZURE_CDN_PURGE, + credentials: { // required upon setting purge to true; + clientId: '', // defaults to env.AZURE_CDN_CLIENT_ID, + tenantId: '', // defaults to env.AZURE_CDN_TENANT_ID, + clientSecret: '', // defaults to env.AZURE_CDN_CLIENT_SECRET, + }, + profile: { // required upon setting purge to true; + subscriptionId: '', // defaults to env.AZURE_CDN_SUBSCRIPTION_ID, + endpointName: '', // defaults to env.AZURE_CDN_ENDPOINT_NAME, + profileName: '', // defaults to env.AZURE_CDN_PROFILE_NAME, + resourceGroupName: '', // defaults to env.AZURE_CDN_RESOURCE_GROUP_NAME, + }, + }, + }, + schema: { + container: true, // optional; store the referenced container in the database + etag: true, // optional; store the etag for the resource + url: true, // optional; generate & store a public URL + }, }); ``` @@ -36,19 +55,6 @@ MyList.add({ }); ``` -### Options: - -The adapter requires an additional `azure` field added to the storage options. It accepts the following values: - -- **accountName**: *(required)* Azure access key. Defaults to `process.env.AZURE_STORAGE_ACCOUNT` - -- **accountKey**: *(required)* Azure access key. Defaults to `process.env.AZURE_STORAGE_ACCESS_KEY` - -- **container**: *(required)* Azure blob store container to store files in. Defaults to `process.env.AZURE_STORAGE_CONTAINER` - -- **generateFilename**: *(optional)* Method to generate the filename. See [keystone-storage-namefunctions](https://github.com/keystonejs/keystone-storage-namefunctions) - - ### Schema The Azure adapter supports all the standard Keystone file schema fields. It also supports storing the following values per-file: diff --git a/index.js b/index.js index d9ee288..0a3b549 100644 --- a/index.js +++ b/index.js @@ -5,16 +5,35 @@ TODO */ // Mirroring keystone 0.4's support of node 0.12. -var assign = require('object-assign'); +var url = require('url'); +var merge = require('lodash/merge'); var azure = require('azure-storage'); +var azureCdnManagementClient = require('azure-arm-cdn'); +var msRestAzure = require('ms-rest-azure'); var ensureCallback = require('keystone-storage-namefunctions/ensureCallback'); var nameFunctions = require('keystone-storage-namefunctions'); var debug = require('debug')('keystone-azure'); var DEFAULT_OPTIONS = { + cacheControl: process.env.AZURE_STORAGE_CACHE_CONTROL, container: process.env.AZURE_STORAGE_CONTAINER, generateFilename: nameFunctions.randomFilename, + cdn: { + customDomain: process.env.AZURE_CDN_CUSTOM_DOMAIN, + purge: process.env.AZURE_CDN_PURGE, + credentials: { + clientId: process.env.AZURE_CDN_CLIENT_ID, + tenantId: process.env.AZURE_CDN_TENANT_ID, + clientSecret: process.env.AZURE_CDN_CLIENT_SECRET, + }, + profile: { + subscriptionId: process.env.AZURE_CDN_SUBSCRIPTION_ID, + endpointName: process.env.AZURE_CDN_ENDPOINT_NAME, + profileName: process.env.AZURE_CDN_PROFILE_NAME, + resourceGroupName: process.env.AZURE_CDN_RESOURCE_GROUP_NAME, + }, + }, }; // azure-storage will automatically use either the environment variables @@ -22,6 +41,27 @@ var DEFAULT_OPTIONS = { // AZURE_STORAGE_CONNECTION_STRING. We'll let the user override that configuration // by specifying `azure.accountName and accountKey` or `connectionString`. +// azure-storage supports defining the cacheControl header of the uploaded blob. +// You can set this via the AZURE_STORAGE_CACHE_CONTROL env var or azure.cacheControl. +// Note, in most cases you'll want to set a sane cache control header - expecially +// when using an Azure CDN. + +// azure-storage supports Azure CDN's in the following way; +// 1. AZURE_CDN_CUSTOM_DOMAIN will replace the blob's public URL +// host with the one provided here and; +// 2. AZURE_CDN_PURGE will ensure we purge the CDN upon file +// upload or deletion. +// Note: In order to support CDN purging you must provide a service principle (not interactive +// login) and properties of the CDN which can be defined as follows; +// AZURE_CDN_CLIENT_ID - Found as Application ID under properties of your App Registration +// AZURE_CDN_TENANT_ID - Found as Directory ID under Properties of Azure Active Directory +// AZURE_CDN_CLIENT_SECRET - A key you create for your App Registration +// AZURE_CDN_SUBSCRIPTION_ID - Found as Subscription ID for your CDN Profile +// AZURE_CDN_RESOURCE_GROUP_NAME - Found as Resource Group under properties of CDN Profile +// AZURE_CDN_PROFILE_NAME - Found in Azure blade title under CDN Profile +// AZURE_CDN_ENDPOINT_NAME - Found in Azure blade title under Endpoint +// See default options above for object definition to override. + // The container configuration is interesting because we could programatically // create the container if it doesn't already exist. But if we did so, what // permissions should it have? If you specify permissions, what should we do @@ -39,8 +79,10 @@ var DEFAULT_OPTIONS = { // See README.md for details and usage examples. function AzureAdapter (options, schema) { - this.options = assign({}, DEFAULT_OPTIONS, options.azure); + this.options = merge({}, DEFAULT_OPTIONS, options.azure); + debug('AzureAdapter options', this.options); + // Setup the blob service. This is used for uploading, downloading and deletion. if (this.options.accountName || this.options.connectionString) { this.blobSvc = azure.createBlobService( this.options.accountName || this.options.connectionString, @@ -53,6 +95,26 @@ function AzureAdapter (options, schema) { this.blobSvc = azure.createBlobService(); } + // Setup the CDN service. This is used for purging, so we only initiate it if the user + // has turned on purging. Start with some sanity checks. + if (this.options.cdn.purge === true && !this.options.cdn.credentials.clientId + || this.options.cdn.purge === true && !this.options.cdn.credentials.tenantId + || this.options.cdn.purge === true && !this.options.cdn.credentials.clientSecret + || this.options.cdn.purge === true && !this.options.cdn.profile.subscriptionId + || this.options.cdn.purge === true && !this.options.cdn.profile.endpointName + || this.options.cdn.purge === true && !this.options.cdn.profile.profileName + || this.options.cdn.purge === true && !this.options.cdn.profile.resourceGroupName) { + throw Error('Azure CDN configuration error: missing credentials (clientId, tentantId, clientSecret, subscriptionId, endpointName, profileName or resourceGroupName'); + } + + if (this.options.cdn.purge === true) { + this.cdnCreds = new msRestAzure.ApplicationTokenCredentials( + this.options.cdn.credentials.clientId, + this.options.cdn.credentials.tenantId, + this.options.cdn.credentials.clientSecret); + this.cdnSvc = new azureCdnManagementClient(this.cdnCreds, this.options.cdn.profile.subscriptionId); + } + // Verify that the container setting exists. if (!this.options.container) { throw Error('Azure storage configuration error: missing container setting'); @@ -61,6 +123,22 @@ function AzureAdapter (options, schema) { // Ensure the generateFilename option takes a callback this.options.generateFilename = ensureCallback(this.options.generateFilename); + + // Purge an Azure CDN + this.purgeCdn = (container, file) => { + debug('attempting to purge with path', `/${container}/${file}`); + this.cdnSvc.endpoints.purgeContent( + this.options.cdn.profile.resourceGroupName, + this.options.cdn.profile.profileName, + this.options.cdn.profile.endpointName, [`/${container}/${file}`], (error, result, request, response) => { + if (error) { + debug('CDN purge failed', error); + console.error('CDN purge failed', error); + } + }); + }; + + return this; } AzureAdapter.compatibilityLevel = 1; @@ -85,11 +163,19 @@ AzureAdapter.prototype.uploadFile = function (file, callback) { debug('Uploading file %s', blobName); var container = self.container; + var uploadOpts = { + contentType: file.mimetype, + }; + + if (self.options.cacheControl) { + uploadOpts.cacheControl = self.options.cacheControl; + } + self.blobSvc.createBlockBlobFromLocalFile( container, blobName, file.path, // original name - { contentType: file.mimetype }, + uploadOpts, function (err, result) { if (err) return callback(err); @@ -105,6 +191,11 @@ AzureAdapter.prototype.uploadFile = function (file, callback) { // azure storage container. file.container = container; + // Purge if required + if (self.options.cdn.purge === true) { + self.purgeCdn(container, blobName); + } + debug('file upload successful'); callback(null, file); }); @@ -115,15 +206,30 @@ AzureAdapter.prototype.uploadFile = function (file, callback) { // work if the container is public or you have set ACLs appropriately. // We could generate a temporary file URL as well using an access token - // file an issue if thats an important use case for you. +// Note that if you're using a CDN, some of these credential issues won't be an problem. AzureAdapter.prototype.getFileURL = function (file) { // From https://msdn.microsoft.com/en-us/library/dd179440.aspx - return this.blobSvc.getUrl(this.container, file.filename); + var fileUrl = url.parse(this.blobSvc.getUrl(this.container, file.filename)); + if (this.options.cdn.customDomain) { + fileUrl = url.parse(this.options.cdn.customDomain + fileUrl.path); + } + debug('getting file url', fileUrl.href); + return fileUrl.href; }; AzureAdapter.prototype.removeFile = function (file, callback) { - this.blobSvc.deleteBlob( - file.container || this.options.container, file.filename, callback - ); + var self = this; + var container = file.container || this.options.container; + debug('Removing file %s', file.filename); + this.blobSvc.deleteBlob(container, file.filename, (error) => { + if (error) { + return callback(error); + } + if (self.options.cdn.purge === true) { + self.purgeCdn(container, file.filename); + } + callback(null, file); + }); }; // Check if a file with the specified filename already exists. Callback called @@ -134,6 +240,7 @@ AzureAdapter.prototype.fileExists = function (filename, callback) { if (err) return callback(err); callback(null, res); }); + }; module.exports = AzureAdapter; diff --git a/package.json b/package.json index efd2644..1ac0674 100644 --- a/package.json +++ b/package.json @@ -10,19 +10,25 @@ "url": "https://github.com/keystonejs/keystone-storage-adapter-azure/issues" }, "dependencies": { + "azure-arm-cdn": "^3.0.0", "azure-storage": "^1.2.0", "debug": "^2.2.0", "keystone-storage-namefunctions": "^1.0.0", - "object-assign": "^4.1.0" + "lodash": "^4.17.4", + "ms-rest-azure": "^2.4.5", + "url": "^0.11.0" }, "devDependencies": { + "chai": "^4.1.2", "dotenv": "^2.0.0", "eslint": "^3.2.2", - "mocha": "^3.0.2" + "mocha": "^3.0.2", + "proxyquire": "^1.8.0", + "sinon": "^4.1.2" }, "scripts": { - "test": "eslint . && mocha test.js", - "lint": "eslint ." + "test": "eslint index.js && mocha test.js", + "lint": "eslint index.js" }, "repository": { "type": "git", diff --git a/test.js b/test.js index e0aa604..bcf15b4 100644 --- a/test.js +++ b/test.js @@ -1,79 +1,538 @@ /* eslint-env node, mocha */ -// NOTE: Requires keystone4 to be linked with npm. Once keystone@0.4 is released -// we can set that as a devDependency. +/** + * FILE ADAPTER UNIT TESTS + */ -// Pull in azure credentials from .env. Your .env file should look like this: -/* -AZURE_STORAGE_ACCOUNT=XXXX -AZURE_STORAGE_ACCESS_KEY=XXXX -AZURE_STORAGE_CONTAINER=XXXX -*/ -// The adapter also will not create the storage container for you, so you'll -// need to do that before running the test. It should have publically readable -// blobs. +// Testing +const sinon = require('sinon'); +const chai = require('chai'); +const expect = chai.expect; +const proxyquire = require('proxyquire'); +const _ = require('lodash'); -require('dotenv').config(); +// Proxy stubs +let azureBlobServiceStub = { + createBlockBlobFromLocalFile: (container, blob, name, opts, callback) => { + return callback(null, { etag: 'etag' }); + }, + deleteBlob: (container, filename, callback) => { + return callback(null); + }, + getUrl: (container, filename) => { + return `http://testhost.com/${container}/${filename}`; + }, + getBlobProperties: (container, filename, callback) => { + return callback(null, true); + }, +}; -const fs = require('fs'); -const assert = require('assert'); +let azureStorageStub = { + createBlobService: function () { + return azureBlobServiceStub; + }, +}; -const AzureAdapter = require('./index'); +let azureArmCdnStub = function () { + return { + endpoints: { + purgeContent: purgeContentSpy, + }, + }; +}; + +let msRestAzureStub = { + ApplicationTokenCredentials: function () { + return; + }, +}; + +// Spies +const purgeContentSpy = sinon.spy(); +const uploadFileSpy = sinon.spy(azureBlobServiceStub, 'createBlockBlobFromLocalFile'); +const removeFileSpy = sinon.spy(azureBlobServiceStub, 'deleteBlob'); +const blobPropertiesSpy = sinon.spy(azureBlobServiceStub, 'getBlobProperties'); describe('azure file field', function () { + + let originalProcessEnv; + let AzureAdapter; + let environmentStub; + let environmentStubNoCdn; + + environmentStub = { + AZURE_STORAGE_ACCOUNT: 'storage account', + AZURE_STORAGE_ACCESS_KEY: 'access key', + AZURE_STORAGE_CONTAINER: 'container', + AZURE_STORAGE_CACHE_CONTROL: 'cache control', + AZURE_CDN_PURGE: true, + AZURE_CDN_CUSTOM_DOMAIN: 'http://custom.domain.com', + AZURE_CDN_CLIENT_ID: 'client id', + AZURE_CDN_TENANT_ID: 'tenant id', + AZURE_CDN_CLIENT_SECRET: 'client secret', + AZURE_CDN_SUBSCRIPTION_ID: 'subscription id', + AZURE_CDN_ENDPOINT_NAME: 'endpoint name', + AZURE_CDN_PROFILE_NAME: 'profile name', + AZURE_CDN_RESOURCE_GROUP_NAME: 'group name', + }; + + environmentStubNoCdn = { + AZURE_STORAGE_ACCOUNT: 'storage account', + AZURE_STORAGE_ACCESS_KEY: 'access key', + AZURE_STORAGE_CONTAINER: 'container', + AZURE_STORAGE_CACHE_CONTROL: 'cache control', + AZURE_CDN_PURGE: false, + }; + beforeEach(function () { - // this.timeout(10000); + originalProcessEnv = _.cloneDeep(process.env); + }); + + afterEach(function () { + process.env = _.cloneDeep(originalProcessEnv); + purgeContentSpy.reset(); + uploadFileSpy.reset(); + removeFileSpy.reset(); + blobPropertiesSpy.reset(); }); - require('keystone/test/fileadapter')(AzureAdapter, { - azure: { /* use environment variables */ }, - }, { - filename: true, - size: true, - mimetype: true, - path: true, - originalname: true, - url: true, - - // Extras for azure - schema: true, - etag: true, - })(); - - it('304s when you request the file using the returned etag'); - it('the returned etag doesnt contain enclosing quotes'); - - describe('fileExists', () => { - // This is stolen from keystone-s3. TODO: The code should be shared somewhere. - it('returns an options object if you ask about a file that does exist', function (done) { - // Piggybacking off the file that gets created as part of the keystone tests. - // This should probably just be exposed as a helper method. - var adapter = this.adapter; - adapter.uploadFile({ - name: 'abcde.txt', - mimetype: 'text/plain', - originalname: 'originalname.txt', - path: this.pathname, - size: fs.statSync(this.pathname).size, - }, function (err, file) { - if (err) throw err; - - adapter.fileExists(file.filename, function (err, result) { - if (err) throw err; - assert.ok(result); - - adapter.removeFile(file, done); + describe('options', () => { + + it('should default properly given a correctly configured environment', () => { + + // Stub the environment + process.env = environmentStub; + + // The adapter to test. + AzureAdapter = proxyquire('./index', { + 'azure-storage': azureStorageStub, + 'ms-rest-azure': msRestAzureStub, + 'azure-arm-cdn': azureArmCdnStub, + }); + + // This should load our environment settings and produce a usable options object. + const adapter = AzureAdapter({}, {}); + + expect(adapter.options.cacheControl).to.equal('cache control'); + expect(adapter.options.container).to.equal('container'); + expect(adapter.options.generateFilename).to.be.a('Function'); + + expect(adapter.options.cdn).to.deep.equal({ + customDomain: 'http://custom.domain.com', + purge: true, + credentials: { + clientId: 'client id', + tenantId: 'tenant id', + clientSecret: 'client secret', + }, + profile: { + subscriptionId: 'subscription id', + endpointName: 'endpoint name', + profileName: 'profile name', + resourceGroupName: 'group name', + }, + }); + + }); + + it('should override environment given options', () => { + + // Stub the environment + process.env = environmentStub; + + // The adapter to test. + AzureAdapter = proxyquire('./index', { + 'azure-storage': azureStorageStub, + 'azure-arm-cdn': azureArmCdnStub, + 'ms-rest-azure': msRestAzureStub, + }); + + const options = { + azure: { + cacheControl: 'no-cache', + container: 'container', + generateFilename: 'test', + cdn: { + customDomain: 'http://test.com', + purge: false, + credentials: { + clientId: 'client', + tenantId: 'tenant', + clientSecret: 'secret', + }, + profile: { + subscriptionId: 'sub', + endpointName: 'endpoint', + profileName: 'profile', + resourceGroupName: 'group', + }, + }, + }, + }; + + // This should load the options above and produce a usable options object. + const adapter = new AzureAdapter(options, {}); + + expect(adapter.options).to.deep.equal(options.azure); + + }); + + it('should load given options with no environment', () => { + + // Stub the environment + process.env = {}; + + // The adapter to test. + AzureAdapter = proxyquire('./index', { + 'azure-storage': azureStorageStub, + 'azure-arm-cdn': azureArmCdnStub, + 'ms-rest-azure': msRestAzureStub, + }); + + const options = { + azure: { + cacheControl: 'no-cache', + container: 'container', + generateFilename: 'test', + cdn: { + customDomain: 'http://test.com', + purge: false, + credentials: { + clientId: 'client', + tenantId: 'tenant', + clientSecret: 'secret', + }, + profile: { + subscriptionId: 'sub', + endpointName: 'endpoint', + profileName: 'profile', + resourceGroupName: 'group', + }, + }, + }, + }; + + // This should load the options above and produce a usable options object. + const adapter = new AzureAdapter(options, {}); + + expect(adapter.options).to.deep.equal(options.azure); + + }); + + it('should not throw an error if purge is false with no creds or profile options set', () => { + + // Stub the environment + process.env = { + AZURE_STORAGE_ACCOUNT: 'storage account', + AZURE_STORAGE_ACCESS_KEY: 'access key', + AZURE_STORAGE_CONTAINER: 'container', + AZURE_STORAGE_CACHE_CONTROL: 'cache control', + AZURE_CDN_PURGE: false, + }; + + // The adapter to test. + AzureAdapter = proxyquire('./index', { + 'azure-storage': azureStorageStub, + 'azure-arm-cdn': azureArmCdnStub, + 'ms-rest-azure': msRestAzureStub, + }); + + // This should load our environment settings and produce a usable options object. + const adapter = new AzureAdapter({}, {}); + + expect(adapter.options.cdn.purge).to.equal(false); + + }); + + it('should throw an error if purge is true with no creds or profile options set', () => { + + // Stub the environment + process.env = { + AZURE_STORAGE_ACCOUNT: 'storage account', + AZURE_STORAGE_ACCESS_KEY: 'access key', + AZURE_STORAGE_CONTAINER: 'container', + AZURE_STORAGE_CACHE_CONTROL: 'cache control', + AZURE_CDN_PURGE: true, + }; + + // The adapter to test. + AzureAdapter = proxyquire('./index', { + 'azure-storage': azureStorageStub, + 'azure-arm-cdn': azureArmCdnStub, + 'ms-rest-azure': msRestAzureStub, + }); + + // Should throw an error. + expect(() => { + const test = new AzureAdapter({}, {}); + }).to.throw('Azure CDN configuration error: missing credentials (clientId, tentantId, clientSecret, subscriptionId, endpointName, profileName or resourceGroupName'); + + }); + + it('should throw an error if no container is set', () => { + + // Stub the environment + process.env = { + AZURE_STORAGE_ACCOUNT: 'storage account', + AZURE_STORAGE_ACCESS_KEY: 'access key', + AZURE_STORAGE_CACHE_CONTROL: 'cache control', + AZURE_CDN_PURGE: false, + }; + + // The adapter to test. + AzureAdapter = proxyquire('./index', { + 'azure-storage': azureStorageStub, + 'azure-arm-cdn': azureArmCdnStub, + 'ms-rest-azure': msRestAzureStub, + }); + + // Should throw an error. + expect(() => { + const test = new AzureAdapter({}, {}); + }).to.throw('Azure storage configuration error: missing container setting'); + + }); + + it('should support base schemas', () => { + + // The adapter to test. + AzureAdapter = proxyquire('./index', { + 'azure-storage': azureStorageStub, + 'azure-arm-cdn': azureArmCdnStub, + 'ms-rest-azure': msRestAzureStub, + }); + + // Should have sane schema + expect(AzureAdapter.SCHEMA_TYPES).to.deep.equal({ + filename: String, + container: String, + etag: String, + }); + + expect(AzureAdapter.SCHEMA_FIELD_DEFAULTS).to.deep.equal({ + filename: true, + container: false, + etag: false, + }); + + }); + + }); + + describe('prototypes with cdn enabled', () => { + + beforeEach(function () { + + // Stub the environment + process.env = environmentStub; + + // The adapter to test. + AzureAdapter = proxyquire('./index', { + 'azure-storage': azureStorageStub, + 'ms-rest-azure': msRestAzureStub, + 'azure-arm-cdn': azureArmCdnStub, + }); + + }); + + it('should invoke azure clients purgeContent method with correct properties', () => { + + // This should load our environment settings and produce a usable options object. + const adapter = new AzureAdapter({}, {}); + + // Run the purge method. + adapter.purgeCdn('container', 'filename'); + + // Ensure its called correctly. + expect(purgeContentSpy.calledOnce).to.equal(true); + expect(purgeContentSpy.calledWith('group name', 'profile name', 'endpoint name', ['/container/filename'])).to.equal(true); + + }); + + it('should be able to upload a file to azure, with cachecontrol and purge an azure cdn', () => { + + // This should load our environment settings and produce a usable options object. + const adapter = new AzureAdapter({ + azure: { + generateFilename: (file, i, callback) => { + return callback(null, 'filename'); + }, + }, + }, {}); + + // Spy on our purgeCdn method. + adapter.purgeCdn = sinon.spy(); + + // Mock a keystone file + const file = { + path: 'original filename', + mimetype: 'mimetype', + }; + + // Test the method. + adapter.uploadFile(file, (err, result) => { + + // Should return with an expected result + expect(result).to.deep.equal({ + mimetype: 'mimetype', + filename: 'filename', + etag: 'etag', + container: 'container', + path: 'original filename', }); + + // Should have run the azure upload with correct params + expect(uploadFileSpy.calledWith('container', 'filename', 'original filename', { contentType: 'mimetype', cacheControl: 'cache control' })).to.equal(true); + + // Should have run the purge. + expect(adapter.purgeCdn.calledWith('container', 'filename')).to.equal(true); + }); }); - it('returns falsy when you ask if fileExists for a nonexistant file', function (done) { - this.adapter.fileExists('filethatdoesnotexist.txt', function (err, result) { - if (err) throw err; - assert(!result); - done(); + it('should be able to load a file url from azure with a defined custom domain', () => { + + // This should load our environment settings and produce a usable options object. + const adapter = new AzureAdapter({}, {}); + + // Mock a keystone file + const file = { + filename: 'filename.txt', + }; + + // Test the method. + const url = adapter.getFileURL(file); + + expect(url).to.equal('http://custom.domain.com/container/filename.txt'); + + }); + + it('should be able to delete a file in azure and purge an azure cdn', () => { + + // This should load our environment settings and produce a usable options object. + const adapter = new AzureAdapter({}, {}); + + // Spy on our purgeCdn method. + adapter.purgeCdn = sinon.spy(); + + // Mock a keystone file + const file = { + filename: 'filename', + path: 'original filename', + mimetype: 'mimetype', + }; + + // Test the method. + adapter.removeFile(file, (err, result) => { + + // Should return with an expected result + expect(result).to.deep.equal({ + filename: 'filename', + path: 'original filename', + mimetype: 'mimetype', + }); + + // Should have run the azure remove with correct params + expect(removeFileSpy.calledWith('container', 'filename')).to.equal(true); + + // Should have run the purge. + expect(adapter.purgeCdn.calledWith('container', 'filename')).to.equal(true); + }); + }); + + it('should be able to tell if a file exists in azure', () => { + + // This should load our environment settings and produce a usable options object. + const adapter = new AzureAdapter({}, {}); + + // Test the method. + adapter.fileExists('filename', (err, result) => { + + // Should return with an expected result + expect(result).to.equal(true); + + // Should have run the azure remove with correct params + expect(blobPropertiesSpy.calledWith('container', 'filename')).to.equal(true); + + }); + }); + + }); + + describe('prototypes with cdn disabled', () => { + + beforeEach(function () { + + // Stub the environment + process.env = environmentStubNoCdn; + + // The adapter to test. + AzureAdapter = proxyquire('./index', { + 'azure-storage': azureStorageStub, + 'ms-rest-azure': msRestAzureStub, + 'azure-arm-cdn': azureArmCdnStub, + }); + + }); + + it('should be able to upload a file to azure and not purge', () => { + + // This should load our environment settings and produce a usable options object. + const adapter = new AzureAdapter({ + azure: { + generateFilename: (file, i, callback) => { + return callback(null, 'filename'); + }, + }, + }, {}); + + // Spy on our purgeCdn method. + adapter.purgeCdn = sinon.spy(); + + // Mock a keystone file + const file = { + path: 'original filename', + mimetype: 'mimetype', + }; + + // Test the method. + adapter.uploadFile(file, (err, result) => { + + // Should not have run the purge. + expect(adapter.purgeCdn.called).to.equal(false); + + }); + + }); + + it('should be able to delete a file in azure and not purge', () => { + + // This should load our environment settings and produce a usable options object. + const adapter = new AzureAdapter({}, {}); + + // Spy on our purgeCdn method. + adapter.purgeCdn = sinon.spy(); + + // Mock a keystone file + const file = { + filename: 'filename', + path: 'original filename', + mimetype: 'mimetype', + }; + + // Test the method. + adapter.removeFile(file, (err, result) => { + + // Should not have run the purge. + expect(adapter.purgeCdn.called).to.equal(false); + + }); + + }); + }); + });