diff --git a/package.json b/package.json index 86674653b5..82a81f55d7 100644 --- a/package.json +++ b/package.json @@ -68,9 +68,11 @@ "fs-extra": "10.1.0", "fx-runner": "1.2.0", "import-fresh": "3.3.0", + "jsonwebtoken": "8.5.1", "mkdirp": "1.0.4", "multimatch": "6.0.0", "mz": "2.7.0", + "node-fetch": "3.2.6", "node-notifier": "10.0.1", "open": "8.4.0", "parse-json": "6.0.2", @@ -111,6 +113,7 @@ "git-rev-sync": "3.0.2", "html-entities": "2.3.3", "mocha": "10.0.0", + "nock": "13.2.8", "nyc": "15.1.0", "prettyjson": "1.2.5", "shelljs": "0.8.5", diff --git a/scripts/audit-deps.js b/scripts/audit-deps.js index 5b643dc1c6..45ea859a85 100755 --- a/scripts/audit-deps.js +++ b/scripts/audit-deps.js @@ -61,14 +61,16 @@ if (auditReport) { } } - for (const advId of Object.keys(auditReport.advisories)) { - const adv = auditReport.advisories[advId]; - - if (exceptions.includes(adv.url)) { - ignoredIssues.push(adv); - continue; + if (auditReport.advisories) { + for (const advId of Object.keys(auditReport.advisories)) { + const adv = auditReport.advisories[advId]; + + if (exceptions.includes(adv.url)) { + ignoredIssues.push(adv); + continue; + } + blockingIssues.push(adv); } - blockingIssues.push(adv); } } diff --git a/src/cmd/sign.js b/src/cmd/sign.js index ebbb056162..a2534a259b 100644 --- a/src/cmd/sign.js +++ b/src/cmd/sign.js @@ -4,6 +4,8 @@ import path from 'path'; import {fs} from 'mz'; import {signAddon as defaultAddonSigner} from 'sign-addon'; +import { signAddon as defaultSubmitAddonSigner } from '../util/submit-addon.js'; +import type { SignResult } from '../util/submit-addon.js'; import defaultBuilder from './build.js'; import getValidatedManifest, {getManifestId} from '../util/manifest.js'; import {withTempDir} from '../util/temp-dir.js'; @@ -12,6 +14,8 @@ import {prepareArtifactsDir} from '../util/artifacts.js'; import {createLogger} from '../util/logger.js'; import type {ExtensionManifest} from '../util/manifest.js'; +export type { SignResult }; + const log = createLogger(import.meta.url); const defaultAsyncFsReadFile: (string) => Promise = @@ -26,6 +30,7 @@ export type SignParams = {| apiProxy: string, apiSecret: string, apiUrlPrefix: string, + apiUseAddonSubmissionApi?: boolean, artifactsDir: string, id?: string, ignoreFiles?: Array, @@ -38,22 +43,18 @@ export type SignParams = {| export type SignOptions = { build?: typeof defaultBuilder, signAddon?: typeof defaultAddonSigner, + submitAddon?: typeof defaultSubmitAddonSigner, preValidatedManifest?: ExtensionManifest, shouldExitProgram?: boolean, }; -export type SignResult = {| - success: boolean, - id: string, - downloadedFiles: Array, -|}; - export default function sign( { apiKey, apiProxy, apiSecret, apiUrlPrefix, + apiUseAddonSubmissionApi = false, artifactsDir, id, ignoreFiles = [], @@ -66,6 +67,7 @@ export default function sign( build = defaultBuilder, preValidatedManifest, signAddon = defaultAddonSigner, + submitAddon = defaultSubmitAddonSigner, }: SignOptions = {} ): Promise { return withTempDir( @@ -88,6 +90,19 @@ export default function sign( const manifestId = getManifestId(manifestData); + if (apiUseAddonSubmissionApi && id && !manifestId) { + throw new UsageError( + `Cannot set custom ID ${id} - addon submission API requires a ` + + 'custom id be specified in the manifest' + ); + } + if (apiUseAddonSubmissionApi && idFromSourceDir && !manifestId) { + throw new UsageError( + 'Cannot use previously auto-generated extension ID: ' + + `${idFromSourceDir} - addon submission API ` + + 'requires a custom id be specified in the manifest' + ); + } if (id && manifestId) { throw new UsageError( `Cannot set custom ID ${id} because manifest.json ` + @@ -111,19 +126,50 @@ export default function sign( log.warn('No extension ID specified (it will be auto-generated)'); } - const signingResult = await signAddon({ - apiKey, - apiSecret, - apiUrlPrefix, - apiProxy, - timeout, - verbose, - id, - xpiPath: buildResult.extensionPath, - version: manifestData.version, - downloadDir: artifactsDir, - channel, - }); + let signingResult; + if (apiUseAddonSubmissionApi) { + if ( + apiUrlPrefix.endsWith('/api/v3/') || apiUrlPrefix.endsWith('/api/v4') + ) { + throw new UsageError( + 'The addon submission API is only available under api/v5/ or higher' + ); + } + if (!channel) { + throw new UsageError( + 'channel is a required paremeter for the addon submission API' + ); + } + if (!apiProxy) { + log.warn("apiProxy isn't yet supported for the addon submission API"); + } + signingResult = await submitAddon({ + apiKey, + apiSecret, + apiUrlPrefix, + // apiProxy, + timeout, + // verbose, + id, + xpiPath: buildResult.extensionPath, + downloadDir: artifactsDir, + channel, + }); + } else { + signingResult = await signAddon({ + apiKey, + apiSecret, + apiUrlPrefix, + apiProxy, + timeout, + verbose, + id, + xpiPath: buildResult.extensionPath, + version: manifestData.version, + downloadDir: artifactsDir, + channel, + }); + } if (signingResult.id) { await saveIdToSourceDir(sourceDir, signingResult.id); @@ -131,7 +177,7 @@ export default function sign( // All information about the downloaded files would have // already been logged by signAddon(). - if (signingResult.success) { + if (signingResult.success && signingResult.id) { log.info(`Extension ID: ${signingResult.id}`); log.info('SUCCESS'); } else { diff --git a/src/program.js b/src/program.js index 2ac3f33574..77659232a0 100644 --- a/src/program.js +++ b/src/program.js @@ -562,6 +562,12 @@ Example: $0 --help run. demandOption: false, type: 'string', }, + 'api-use-addon-submission-api': { + describe: + '[Experimental] sign using the addon submission api - /v5+ only', + demandOption: false, + type: 'boolean', + }, 'id': { describe: 'A custom ID for the extension. This has no effect if the ' + diff --git a/src/util/submit-addon.js b/src/util/submit-addon.js new file mode 100644 index 0000000000..501405a5ab --- /dev/null +++ b/src/util/submit-addon.js @@ -0,0 +1,397 @@ +/* @flow */ +import { createWriteStream, promises as fsPromises } from 'fs'; +import { pipeline } from 'stream'; +import { promisify } from 'util'; + +// eslint-disable-next-line no-shadow +import fetch, { FormData, fileFromSync, Response } from 'node-fetch'; +import defaultJwt from 'jsonwebtoken'; + +export type SignResult = {| + success: boolean, + id: string | null, + downloadedFiles: Array, +|}; + +type ClientConstructorParams = {| + apiKey: string, + apiSecret: string, + apiUrlPrefix: string, + apiJwtExpiresIn?: number, + debugLogging?: boolean, + validationCheckInterval?: number, + validationCheckTimeout?: number, + approvalCheckInterval?: number, + approvalCheckTimeout?: number, + logger?: any, + downloadDir?: string, +|}; + +export default class Client { + apiKey: string; + apiSecret: string; + apiUrlPrefix: string; + apiJwtExpiresIn: number; + debugLogging: boolean; + validationCheckInterval: number; + validationCheckTimeout: number; + approvalCheckInterval: number; + approvalCheckTimeout: number; + logger: any; + downloadDir: string; + + constructor({ + apiKey, + apiSecret, + apiUrlPrefix, + apiJwtExpiresIn = 60 * 5, // 5 minutes + debugLogging = false, + validationCheckInterval = 1000, + validationCheckTimeout = 300000, // 5 minutes. + approvalCheckInterval = 1000, + approvalCheckTimeout = 900000, // 15 minutes. + logger = console, + downloadDir = process.cwd(), + }: ClientConstructorParams) { + this.apiKey = apiKey; + this.apiSecret = apiSecret; + this.apiUrlPrefix = `${apiUrlPrefix}addons/`; + this.apiJwtExpiresIn = apiJwtExpiresIn; + this.validationCheckInterval = validationCheckInterval; + this.validationCheckTimeout = validationCheckTimeout; + this.approvalCheckInterval = approvalCheckInterval; + this.approvalCheckTimeout = approvalCheckTimeout; + this.debugLogging = debugLogging; + this.logger = logger; + this.downloadDir = downloadDir; + } + + fileFromSync(path: string): File { + return fileFromSync(path); + } + + async doUploadSubmit(xpiPath: string, channel: string): Promise { + const url = `${this.apiUrlPrefix}upload/`; + const formData = new FormData(); + formData.set('channel', channel); + formData.set('upload', this.fileFromSync(xpiPath)); + const { uuid } = await this.getJson( + await this.fetch(url, 'POST', formData) + ); + return this.waitForValidation(uuid); + } + + waitForValidation(uuid: string): Promise { + let validationCheckTimeout; + + return new Promise((resolve, reject) => { + const uploadDetailUrl = `${this.apiUrlPrefix}upload/${uuid}/`; + const abortTimeout = setTimeout(() => { + clearTimeout(validationCheckTimeout); + + reject(new Error('Validation Timeout.')); + }, this.validationCheckTimeout); + + const pollValidationStatus = async () => { + try { + const detailResponse = await this.fetch(uploadDetailUrl); + if (!detailResponse.ok) { + reject( + new Error( + `Getting upload details failed: ${detailResponse.statusText}.` + ) + ); + } + const detailResponseData = await detailResponse.json(); + + if (detailResponseData.processed) { + this.logger.log( + 'Validation results:', + detailResponseData.validation + ); + if (detailResponseData.valid) { + clearTimeout(abortTimeout); + resolve(detailResponseData.uuid); + } else { + this.logger.log('Validation failed.'); + clearTimeout(abortTimeout); + + reject(new Error(detailResponseData.url)); + } + } else { + // Validation is still in progress, so wait for a while and try again. + validationCheckTimeout = setTimeout( + pollValidationStatus, + this.validationCheckInterval + ); + } + } catch (err) { + clearTimeout(abortTimeout); + reject(err); + } + }; + + pollValidationStatus(); + }); + } + + async doNewAddonSubmit(metaDataJSON: any, uuid: string): Promise { + const url = `${this.apiUrlPrefix}addon/`; + const jsonData = { version: { upload: uuid }, ...metaDataJSON }; + const response = await this.fetch(url, 'POST', JSON.stringify(jsonData)); + return this.getJson(response); + } + + doNewAddonOrVersionSubmit( + addonId: string, + metaDataJSON: any, + uuid: string + ): Promise { + const url = `${this.apiUrlPrefix}addon/${addonId}/`; + const jsonData = { version: { upload: uuid }, ...metaDataJSON }; + return this.fetch(url, 'PUT', JSON.stringify(jsonData)); + } + + waitForApproval( + extractFileFromData: Function, + detailUrl: string, + ): Promise { + let approvalCheckTimeout; + + return new Promise((resolve, reject) => { + const abortTimeout = setTimeout(() => { + clearTimeout(approvalCheckTimeout); + + reject(new Error('Approval Timeout.')); + }, this.approvalCheckTimeout); + + const pollApprovalStatus = async () => { + try { + const detailResponse = await this.fetch(detailUrl); + if (!detailResponse.ok) { + return reject(new Error('Getting addon details failed.')); + } + const detailResponseData = await detailResponse.json(); + + const file = extractFileFromData(detailResponseData); + if (file.status === 'public') { + clearTimeout(abortTimeout); + resolve(file.url); + } else { + // The add-on hasn't been approved yet, so wait for a while and try again. + approvalCheckTimeout = setTimeout( + pollApprovalStatus, + this.approvalCheckInterval + ); + } + } catch (err) { + clearTimeout(abortTimeout); + reject(err); + } + }; + + pollApprovalStatus(); + }); + } + + async getJson(response: typeof Response): Promise { + if (response.status < 100 || response.status >= 500) { + return new Promise((resolve, reject) => { + reject(new Error(`Getting response failed: ${response.status}.`)); + }); + } else { + const data = await response.json(); + return new Promise((resolve, reject) => { + if (!response.ok) { + this.logger.log(data); + reject(new Error('Bad Request.')); + } else { + resolve(data); + } + }); + } + } + + fetch( + url: string, + method: string = 'GET', + body?: typeof FormData | string, + jwt: typeof defaultJwt = defaultJwt + ): Promise { + const authToken = jwt.sign({ iss: this.apiKey }, this.apiSecret, { + algorithm: 'HS256', + expiresIn: this.apiJwtExpiresIn, + }); + + this.logger.log(`Fetching URL: ${url}`); + let headers; + if (typeof body === 'string') { + headers = { + 'Authorization': `JWT ${authToken}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }; + } else { + headers = { + Authorization: `JWT ${authToken}`, + Accept: 'application/json', + }; + } + return fetch(url, { method, body, headers }); + } + + async downloadSignedFile( + fileUrl: string, + addonId: string + ): Promise { + const filename = fileUrl.split('/').pop(); // get the name from fileUrl + const dest = `${this.downloadDir}/${filename}`; + const response = await this.fetch(fileUrl); + if (!response.ok || !response.body) { + this.logger.log(`Download of signed xpi failed: ${response.status}.`); + throw new Error(`Downloading ${filename} failed`); + } + await promisify(pipeline)(response.body, createWriteStream(dest)); + return Promise.resolve({ + success: true, + id: addonId, + downloadedFiles: [filename], + }); + } + + async postNewAddon( + xpiPath: string, + channel: string, + metaDataJSON: any + ): Promise { + const extractFileFromData = (data: any): any => { + return channel === 'listed' + ? data.current_version.file + : data.latest_unlisted_version.file; + }; + + const upload_uuid = await this.doUploadSubmit(xpiPath, channel); + + const submitResponse = await this.doNewAddonSubmit( + upload_uuid, metaDataJSON + ); + const fileUrl = await this.waitForApproval( + extractFileFromData, + `${this.apiUrlPrefix}addon/${submitResponse.slug}/` + ); + + return this.downloadSignedFile(fileUrl, submitResponse.guid); + } + + async putVersion( + xpiPath: string, + channel: string, + addonId: string, + metaDataJSON: any + ): Promise { + const extractFileFromData = (data: any) => data.file; + + const upload_uuid = await this.doUploadSubmit(xpiPath, channel); + + await this.doNewAddonOrVersionSubmit( + upload_uuid, addonId, metaDataJSON); + + const url = ( + `${this.apiUrlPrefix}addon/${addonId}/versions/?filter=all_with_unlisted` + ); + const [{id: newVersionId}] = await this.getJson(await this.fetch(url)); + + const fileUrl = await this.waitForApproval( + extractFileFromData, + `${this.apiUrlPrefix}addon/${addonId}/versions/${newVersionId}/`, + ); + + return this.downloadSignedFile(fileUrl, addonId); + } +} + +type signAddonParams = {| + apiKey: string, + apiSecret: string, + apiUrlPrefix: string, + // apiProxy, + timeout: number, + // verbose, + id?: string, + xpiPath: string, + downloadDir: string, + channel: string, +|} + +export async function signAddon({ + apiKey, + apiSecret, + apiUrlPrefix, + // apiProxy, + timeout, + // verbose, + id, + xpiPath, + downloadDir, + channel, +}: signAddonParams): Promise { + function reportEmpty(name) { + throw new Error(`required argument was empty: ${name}`); + } + + if (!xpiPath) { + reportEmpty('xpiPath'); + } + + if (!apiSecret) { + reportEmpty('apiSecret'); + } + + if (!apiKey) { + reportEmpty('apiKey'); + } + + try { + const stats = await fsPromises.stat(xpiPath); + + if (!stats.isFile()) { + throw new Error(`not a file: ${xpiPath}`); + } + } catch (statError) { + throw new Error(`error with ${xpiPath}: ${statError}`); + } + + const client = new Client({ + apiKey, + apiSecret, + apiUrlPrefix, + // apiProxy, + validationCheckTimeout: timeout, + approvalCheckTimeout: timeout, + // verbose, + downloadDir, + }); + + try { + if (id === undefined) { + return client.postNewAddon( + xpiPath, + channel, + {}, + ); + } else { + return client.putVersion( + xpiPath, + channel, + id, + {}, + ); + } + } catch (clientError) { + return Promise.resolve({ + success: false, + id: null, + downloadedFiles: [], + }); + } +} diff --git a/tests/unit/test-cmd/test.sign.js b/tests/unit/test-cmd/test.sign.js index e18e9a9830..819f4d79c5 100644 --- a/tests/unit/test-cmd/test.sign.js +++ b/tests/unit/test-cmd/test.sign.js @@ -45,12 +45,14 @@ describe('sign', () => { downloadedFiles: [], }; const signAddon = sinon.spy(() => Promise.resolve(signingResult)); + const submitAddon = sinon.spy(() => Promise.resolve(signingResult)); return { signingConfig, build, buildResult, signAddon, + submitAddon, signingResult, preValidatedManifest: basicManifest, }; @@ -100,6 +102,34 @@ describe('sign', () => { } )); + it('builds and signs an extension with submission api', () => withTempDir( + // This test only stubs out the signer in an effort to integrate + // all other parts of the process. + (tmpDir) => { + const stubs = getStubs(); + const sourceDir = path.join(tmpDir.path(), 'source-dir'); + const copyDirAsPromised = promisify(copyDir); + return copyDirAsPromised(fixturePath('minimal-web-ext'), sourceDir) + .then(() => completeSignCommand({ + sourceDir, + artifactsDir: path.join(tmpDir.path(), 'artifacts'), + ...stubs.signingConfig, + apiUseAddonSubmissionApi: true, + channel: 'listed', + apiUrlPrefix: '/api/v5', + }, { + submitAddon: stubs.submitAddon, + })) + .then((result) => { + assert.equal(result.success, true); + // Do a sanity check that a built extension was passed to the + // signer. + assert.include(stubs.submitAddon.firstCall.args[0].xpiPath, + 'minimal_extension-1.0.zip'); + }); + } + )); + it('allows an empty application ID when signing', () => withTempDir( (tmpDir) => { const stubs = getStubs(); @@ -141,6 +171,70 @@ describe('sign', () => { } )); + it( + "doesn't allow a custom ID when no ID in manifest.json with submission api", + () => withTempDir( + (tmpDir) => { + const customId = 'some-custom-id'; + const stubs = getStubs(); + return sign( + tmpDir, stubs, + { + extraArgs: { + id: customId, + apiUseAddonSubmissionApi: true, + apiUrlPrefix: '/api/v5', + }, + extraOptions: { + preValidatedManifest: manifestWithoutApps, + }, + }) + .then(makeSureItFails()) + .catch(onlyInstancesOf(UsageError, (error) => { + assert.match(error.message, /Cannot set custom ID some-custom-id/); + assert.match( + error.message, + /requires a custom id be specified in the manifest/); + })); + } + )); + + it.skip( + "doesn't allow ID file when no ID in manifest.json with submission api", + () => withTempDir( + (tmpDir) => { + const sourceDir = path.join(tmpDir.path(), 'source-dir'); + const stubs = getStubs(); + return fs.mkdir(sourceDir) + .then(() => saveIdToSourceDir(sourceDir, 'some-other-id')) + // Now, make a signing call with a custom ID. + .then(() => sign(tmpDir, stubs, { + extraArgs: { + apiUseAddonSubmissionApi: true, + apiUrlPrefix: '/api/v5', + channel: 'listed', + }, + extraOptions: { + preValidatedManifest: manifestWithoutApps, + }, + })) + .then(makeSureItFails()) + .catch(onlyInstancesOf(UsageError, (error) => { + assert.match( + error.message, + /Cannot use previously auto-generated extension ID/ + ); + assert.match( + error.message, + /some-other-id - / + ); + assert.match( + error.message, + /requires a custom id be specified in the manifest/); + })); + } + )); + it('prefers a custom ID over an ID file', () => withTempDir( (tmpDir) => { const sourceDir = path.join(tmpDir.path(), 'source-dir'); @@ -193,7 +287,6 @@ describe('sign', () => { it('remembers auto-generated IDs for successive signing', () => withTempDir( (tmpDir) => { - function _sign() { const signAddon = sinon.spy(() => Promise.resolve({ ...stubs.signingResult, @@ -237,6 +330,55 @@ describe('sign', () => { } )); + it('requires a channel for submission API', () => withTempDir( + (tmpDir) => { + const stubs = getStubs(); + return sign( + tmpDir, stubs, + { + extraArgs: { + apiUseAddonSubmissionApi: true, + apiUrlPrefix: '/api/v5', + }, + extraOptions: { + preValidatedManifest: manifestWithoutApps, + }, + }) + .then(makeSureItFails()) + .catch(onlyInstancesOf(UsageError, (error) => { + assert.match( + error.message, + /channel is a required paremeter for the addon submission API/); + })); + } + )); + + ['/api/v4', 'api/v3'].forEach((apiUrlPrefix: string) => { + it(`does not allow ${apiUrlPrefix} to use the addon submission API`, () => { + (tmpDir) => { + const stubs = getStubs(); + return sign( + tmpDir, stubs, + { + extraArgs: { + apiUseAddonSubmissionApi: true, + apiUrlPrefix, + }, + extraOptions: { + preValidatedManifest: manifestWithoutApps, + }, + }) + .then(makeSureItFails()) + .catch(onlyInstancesOf(UsageError, (error) => { + assert.equal( + error.message, + 'The addon submission API is only available under api/v5/ ' + + 'or higher'); + })); + }; + }); + }); + it('returns a signing result', () => withTempDir( (tmpDir) => { const stubs = getStubs(); diff --git a/tests/unit/test-util/test.submit-addon.js b/tests/unit/test-util/test.submit-addon.js new file mode 100644 index 0000000000..4462912c34 --- /dev/null +++ b/tests/unit/test-util/test.submit-addon.js @@ -0,0 +1,482 @@ +/* @flow */ +import { promises as fsPromises, readFileSync } from 'fs'; +import path from 'path'; + +import { fs } from 'mz'; +import { assert, expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import * as sinon from 'sinon'; +import nock from 'nock'; +import { File, FormData } from 'node-fetch'; +import jwt from 'jsonwebtoken'; + +import Client, { signAddon } from '../../../src/util/submit-addon.js'; +import { makeSureItFails } from '../helpers.js'; +import { withTempDir } from '../../../src/util/temp-dir.js'; + +describe('util.submit-addon', () => { + + describe('signAddon', () => { + let statStub; + let postNewAddonStub; + let putVersionStub; + + beforeEach(() => { + statStub = sinon.stub(fsPromises, 'stat').resolves({isFile() { + return true; + }}); + postNewAddonStub = sinon.stub(Client.prototype, 'postNewAddon'); + putVersionStub = sinon.stub(Client.prototype, 'putVersion'); + }); + + afterEach(() => { + statStub.restore(); + postNewAddonStub.restore(); + putVersionStub.restore(); + }); + + const signAddonDefaults = { + apiKey: 'some-key', + apiSecret: 'some-secret', + apiUrlPrefix: 'https://some.url/api/v5', + timeout: 1, + downloadDir: '/some-dir/', + xpiPath: '/some.xpi', + channel: 'some-channel', + }; + + it.skip('creates Client with parameters', async () => { + const apiKey = 'fooKey'; + const apiSecret = 'fooSecret'; + const apiUrlPrefix = 'fooPrefix'; + const downloadDir = '/foo'; + const clientSpy = sinon.spy(Client); + + await signAddon({ + ...signAddonDefaults, + apiKey, + apiSecret, + apiUrlPrefix, + downloadDir, + }); + + sinon.assert.called(clientSpy); + + }); + + it('calls postNewAddon if `id` is undefined', async () => { + const xpiPath = 'this/path/xpi.xpi'; + const channel = 'thisChannel'; + await signAddon({ + ...signAddonDefaults, + xpiPath, + channel, + }); + sinon.assert.notCalled(putVersionStub); + sinon.assert.calledWith(postNewAddonStub, xpiPath, channel, {}); + }); + + it('calls putVersion if `id` is defined', async () => { + const xpiPath = 'this/path/xpi.xpi'; + const channel = 'thisChannel'; + const id = '@thisID'; + await signAddon({ + ...signAddonDefaults, + xpiPath, + channel, + id, + }).then(() => {}); + sinon.assert.notCalled(postNewAddonStub); + sinon.assert.calledWith(putVersionStub, xpiPath, channel, id, {}); + }); + + ['xpiPath', 'apiSecret', 'apiKey'].forEach((param: string) => { + it(`throws an error if ${param} is empty`, () => { + return signAddon({ + ...signAddonDefaults, + [param]: '', + }) + .then(makeSureItFails()) + .catch((error) => { + assert.equal( + error.message, + `required argument was empty: ${param}` + ); + }); + }); + }); + + it('throws error if xpiPath is invalid', () => { + statStub.restore(); + return signAddon(signAddonDefaults) + .then(makeSureItFails()) + .catch((error) => { + const xpiPath = signAddonDefaults.xpiPath; + assert.include( + error.message, + `error with ${xpiPath}: Error: ENOENT: no such file or directory` + ); + }); + }); + + it('catch errors and returns a SignResult promise', async () => { + const xpiPath = 'this/path/xpi.xpi'; + const channel = 'thisChannel'; + postNewAddonStub.throws(); + + const result = await signAddon({ + ...signAddonDefaults, + xpiPath, + channel, + }); + expect(result).to.eql({ + success: false, + id: null, + downloadedFiles: [], + }); + }); + }); + + describe('Client', () => { + const apiHost = 'http://not-a-real-amo-api.com'; + const apiPath = '/api/v5/'; + + const clientDefaults = { + apiKey: 'fake-api-key', + apiSecret: 'fake-api-secret', + apiUrlPrefix: `${apiHost}${apiPath}`, + approvalCheckInterval: 0, + validationCheckInterval: 0, + }; + + const sampleUploadDetail = { + uuid: '1234-5678', + channel: 'a-channel', + processed: true, + submitted: false, + url: 'http://amo/validation-results/', + valid: true, + validation: {}, + version: '1.0', + }; + + const sampleVersionDetail = { + // Note: most of the fields are ommited here, these are just the essentials. + id: 456, + channel: 'a-channel', + file: { + id: 789, + hash: 'abcd', + status: 'nominated', + url: 'http://amo/download-url', + }, + version: '1.0', + }; + + const sampleAddonDetail = { + // Note: most of the fields are ommited here, these are just the essentials. + id: 9876, + current_version: sampleVersionDetail, + guid: '@this-guid', + slug: 'this_addon', + status: 'unreviewed', + }; + + describe('doSubmitUpload', () => { + it('submits the xpi', async () => { + const client = new Client(clientDefaults); + sinon.stub(client, 'fileFromSync') + .returns(new File([], 'foo.xpi')); + nock(apiHost).post(`${apiPath}addons/upload/`) + .reply(200, sampleUploadDetail); + const xpiPath = '/some/path.xpi'; + const channel = 'someChannel'; + const waitStub = sinon.stub(client, 'waitForValidation') + .resolves(sampleUploadDetail.uuid); + + const returnUuid = await client.doUploadSubmit(xpiPath, channel); + assert.equal(sampleUploadDetail.uuid, returnUuid); + sinon.assert.calledWith(waitStub, sampleUploadDetail.uuid); + }); + }); + + describe('waitForValidation', () => { + it('aborts validation check after timeout', async () => { + const client = new Client({ + ...clientDefaults, + // This causes an immediate failure. + validationCheckTimeout: 0, + validationCheckInterval: 1, + }); + const uploadUuid = '@some-guid'; + nock(apiHost).get(`${apiPath}addons/upload/${uploadUuid}/`) + .reply(200, {}); + + await client + .waitForValidation(uploadUuid) + .then(makeSureItFails()) + .catch((error) => { + assert.equal(error.message, 'Validation Timeout.'); + }); + }); + + it('waits for validation that passes', async () => { + const client = new Client({ + ...clientDefaults, + validationCheckTimeout: 100, + validationCheckInterval: 1, + }); + const uploadUuid = '@some-guid'; + nock(apiHost).get(`${apiPath}addons/upload/${uploadUuid}/`) + .times(2).reply(200, {}); + nock(apiHost).get(`${apiPath}addons/upload/${uploadUuid}/`) + .reply(200, {processed: true, valid: true, uuid: uploadUuid}); + + const returnUuid = await client.waitForValidation(uploadUuid); + assert.equal(returnUuid, uploadUuid); + }); + + it('waits for validation that fails', async () => { + const client = new Client({ + ...clientDefaults, + validationCheckTimeout: 100, + validationCheckInterval: 1, + }); + const uploadUuid = '@some-guid'; + const validationUrl = `${apiHost}/to/validation/report`; + nock(apiHost).get(`${apiPath}addons/upload/${uploadUuid}/`) + .times(2).reply(200, {}); + nock(apiHost).get(`${apiPath}addons/upload/${uploadUuid}/`) + .reply(200, {processed: true, valid: false, url: validationUrl}); + + await client.waitForValidation(uploadUuid) + .then(makeSureItFails()) + .catch((error) => { + assert.equal(error.message, validationUrl); + }); + }); + }); + + describe('doNewAddonSubmit', () => { + it('posts the upload uuid', async () => { + const client = new Client(clientDefaults); + nock(apiHost).post(`${apiPath}addons/addon/`) + .reply(202, sampleAddonDetail); + const uploadUuid = 'some-uuid'; + + const returnData = await client.doNewAddonSubmit({}, uploadUuid); + expect(returnData).to.eql(sampleAddonDetail); + }); + }); + + describe('doNewAddonOrVersionSubmit', () => { + it('puts the upload uuid to the addon detail', async () => { + const client = new Client(clientDefaults); + const guid = '@some-addon-guid'; + nock(apiHost).put(`${apiPath}addons/addon/${guid}/`) + .reply(202, sampleAddonDetail); + const uploadUuid = 'some-uuid'; + + await client.doNewAddonOrVersionSubmit(guid, {}, uploadUuid); + }); + }); + + describe('waitForApproval', () => { + it('aborts approval wait after timeout', async () => { + const client = new Client({ + ...clientDefaults, + // This causes an immediate failure. + approvalCheckTimeout: 0, + approvalCheckInterval: 1, + }); + const detailPath = `${apiPath}addons/addon/random-addon-id/`; + const extractFileFromData = () => {}; + + nock(apiHost).get(detailPath) + .reply(200, {}); + + await client + .waitForApproval(extractFileFromData, apiHost + detailPath) + .then(makeSureItFails()) + .catch((error) => { + assert.equal(error.message, 'Approval Timeout.'); + }); + }); + + it('waits for approval', async () => { + const client = new Client({ + ...clientDefaults, + validationCheckTimeout: 100, + validationCheckInterval: 1, + }); + const detailPath = `${apiPath}addons/addon/random-addon-id/`; + const extractFileFromData = (data) => data; + const url = `${apiHost}file/download/url`; + nock(apiHost).get(detailPath) + .reply(200, {}); + nock(apiHost).get(detailPath) + .reply(200, {status: 'nominated', url: 'http://other.url'}); + nock(apiHost).get(detailPath) + .reply(200, {status: 'public', url}); + + const fileUrl = await client.waitForApproval( + extractFileFromData, + apiHost + detailPath, + ); + assert.equal(fileUrl, url); + }); + }); + + describe('downloadSignedFile', () => { + const filename = 'download.xpi'; + const filePath = `/path/to/${filename}`; + const fileUrl = `${apiHost}${filePath}`; + const addonId = '@some-addon-id'; + + it('downloads the file to tmpdir', () => withTempDir(async (tmpDir) => { + const client = new Client( + { ...clientDefaults, downloadDir: tmpDir.path() }, + ); + const fileData = 'a'; + + nock(apiHost).get(filePath).reply(200, fileData); + + const result = await client.downloadSignedFile(fileUrl, addonId); + expect(result).to.eql({ + success: true, + id: addonId, + downloadedFiles: [filename], + }); + const fullPath = path.join(tmpDir.path(), filename); + const stat = await fs.stat(fullPath); + assert.equal(stat.isFile(), true); + assert.equal(readFileSync(fullPath), fileData); + })); + + it('raises when the response is not ok', async () => { + const client = new Client(clientDefaults); + nock(apiHost).get(filePath).reply(404, 'a'); + + await client.downloadSignedFile(fileUrl, addonId) + .then(makeSureItFails()) + .catch((error) => { + assert.equal(error.message, `Downloading ${filename} failed`); + }); + }); + }); + + describe('postNewAddon', () => { + /// + }); + + describe('putVersion', () => { + /// + }); + + describe('getJson', () => { + const client = new Client(clientDefaults); + + it('rejects with a promise on not ok responses', async () => { + nock(apiHost).get('/').reply(400, {}); + + await client + .getJson(await client.fetch(`${apiHost}/`)) + .then(makeSureItFails()) + .catch((error) => { + assert.equal(error.message, 'Bad Request.'); + }); + }); + + it('rejects with a promise on < 100 responses', async () => { + nock(apiHost).get('/').reply(99, {}); + + await client + .getJson(await client.fetch(`${apiHost}/`)) + .then(makeSureItFails()) + .catch((error) => { + assert.equal( + error.message, + 'Getting response failed: 99.', + ); + }); + }); + + it('rejects with a promise on >= 500 responses', async () => { + nock(apiHost).get('/').reply(500, {}); + + await client + .getJson(await client.fetch(`${apiHost}/`)) + .then(makeSureItFails()) + .catch((error) => { + assert.equal( + error.message, + 'Getting response failed: 500.', + ); + }); + }); + + it('resolves with a promise containing response json', async () => { + const nockJson = {thing: ['other'], this: {that: 1}}; + nock(apiHost).get('/').reply(200, nockJson); + + const responseJson = await client + .getJson(await client.fetch(`${apiHost}/`)); + + expect(responseJson).to.eql(nockJson); + }); + }); + + describe('fetch', () => { + const client = new Client(clientDefaults); + let jwtSignSpy; + + beforeEach(() => { + jwtSignSpy = sinon.spy(jwt, 'sign'); + }); + + afterEach(() => { + jwtSignSpy.restore(); + }); + + it('sets json content type for string type body', async () => { + nock( + apiHost, + {reqheaders: { + 'Authorization': (headerValue) => + headerValue === `JWT ${jwtSignSpy.firstCall.returnValue}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }}, + ).post('/').reply(200, {}); + + await client.fetch(`${apiHost}/`, 'POST', 'body'); + }); + + it("doesn't set content type for FormData type body", async () => { + nock( + apiHost, + {reqheaders: { + Authorization: (headerValue) => + headerValue === `JWT ${jwtSignSpy.firstCall.returnValue}`, + Accept: 'application/json', + }}, + ).post('/').reply(200, {}); + + await client.fetch(`${apiHost}/`, 'POST', new FormData()); + }); + + it("doesn't set content type for no body", async () => { + nock( + apiHost, + {reqheaders: { + Authorization: (headerValue) => + headerValue === `JWT ${jwtSignSpy.firstCall.returnValue}`, + Accept: 'application/json', + }}, + ).post('/').reply(200, {}); + + await client.fetch(`${apiHost}/`, 'POST'); + }); + }); + }); +});