diff --git a/src/cmd/sign.js b/src/cmd/sign.js index 9b1ecf2317..15d88d426d 100644 --- a/src/cmd/sign.js +++ b/src/cmd/sign.js @@ -40,6 +40,7 @@ export default function sign( verbose, channel, amoMetadata, + uploadSourceCode, webextVersion, }, { @@ -152,6 +153,7 @@ export default function sign( validationCheckTimeout: timeout, approvalCheckTimeout: approvalTimeout !== undefined ? approvalTimeout : timeout, + submissionSource: uploadSourceCode, }); } else { const { diff --git a/src/program.js b/src/program.js index d958b64889..50a6b5f7b1 100644 --- a/src/program.js +++ b/src/program.js @@ -601,6 +601,14 @@ Example: $0 --help run. 'Only used with `use-submission-api`', type: 'string', }, + 'upload-source-code': { + describe: + 'Path to an archive file containing human readable source code of this submission, ' + + 'if the code in --source-dir has been processed to make it unreadable. ' + + 'See https://extensionworkshop.com/documentation/publish/source-code-submission/ for ' + + 'details. Only used with `use-submission-api`', + type: 'string', + }, }, ) .command('run', 'Run the extension', commands.run, { diff --git a/src/util/submit-addon.js b/src/util/submit-addon.js index 709406c150..160cbf9dbf 100644 --- a/src/util/submit-addon.js +++ b/src/util/submit-addon.js @@ -185,7 +185,32 @@ export default class Client { return this.fetchJson(url, 'PUT', JSON.stringify(jsonData)); } - async doAfterSubmit(addonId, newVersionId, editUrl) { + async doFormDataPatch(data, addonId, versionId) { + const patchUrl = new URL( + `addon/${addonId}/versions/${versionId}/`, + this.apiUrl, + ); + try { + const formData = new FormData(); + for (const field in data) { + formData.set(field, data[field]); + } + + const response = await this.fetch(patchUrl, 'PATCH', formData); + if (!response.ok) { + throw new Error(`response status was ${response.status}`); + } + } catch (error) { + log.info(`Upload of ${Object.keys(data)} failed: ${error}.`); + throw new Error(`Uploading ${Object.keys(data)} failed`); + } + } + + async doAfterSubmit(addonId, newVersionId, editUrl, patchData) { + if (patchData && patchData.version) { + log.info(`Submitting ${Object.keys(patchData.version)} to version`); + await this.doFormDataPatch(patchData.version, addonId, newVersionId); + } if (this.approvalCheckTimeout > 0) { const fileUrl = new URL( await this.waitForApproval(addonId, newVersionId), @@ -237,7 +262,7 @@ export default class Client { } async fetch(url, method = 'GET', body) { - log.info(`Fetching URL: ${url.href}`); + log.info(`${method}ing URL: ${url.href}`); let headers = { Authorization: await this.apiAuth.getAuthHeader(), Accept: 'application/json', @@ -350,6 +375,7 @@ export default class Client { uploadUuid, savedIdPath, metaDataJson, + patchData, saveIdToFileFunc = saveIdToFile, ) { const { @@ -362,15 +388,15 @@ export default class Client { log.info('You must add the following to your manifest:'); log.info(`"browser_specific_settings": {"gecko": {"id": "${addonId}"}}`); - return this.doAfterSubmit(addonId, newVersionId, editUrl); + return this.doAfterSubmit(addonId, newVersionId, editUrl, patchData); } - async putVersion(uploadUuid, addonId, metaDataJson) { + async putVersion(uploadUuid, addonId, metaDataJson, patchData) { const { version: { id: newVersionId, edit_url: editUrl }, } = await this.doNewAddonOrVersionSubmit(addonId, uploadUuid, metaDataJson); - return this.doAfterSubmit(addonId, newVersionId, editUrl); + return this.doAfterSubmit(addonId, newVersionId, editUrl, patchData); } } @@ -388,6 +414,7 @@ export async function signAddon({ savedIdPath, savedUploadUuidPath, metaDataJson = {}, + submissionSource, userAgentString, SubmitClient = Client, ApiAuthClass = JwtApiAuth, @@ -396,7 +423,7 @@ export async function signAddon({ const stats = await fsPromises.stat(xpiPath); if (!stats.isFile()) { - throw new Error(`not a file: ${xpiPath}`); + throw new Error('not a file'); } } catch (statError) { throw new Error(`error with ${xpiPath}: ${statError}`); @@ -423,14 +450,33 @@ export async function signAddon({ channel, savedUploadUuidPath, ); + const patchData = {}; + // if we have a source file we need to upload we patch after the create + if (submissionSource) { + try { + const stats2 = await fsPromises.stat(submissionSource); + + if (!stats2.isFile()) { + throw new Error('not a file'); + } + } catch (statError) { + throw new Error(`error with ${submissionSource}: ${statError}`); + } + patchData.version = { source: client.fileFromSync(submissionSource) }; + } // We specifically need to know if `id` has not been passed as a parameter because // it's the indication that a new add-on should be created, rather than a new version. if (id === undefined) { - return client.postNewAddon(uploadUuid, savedIdPath, metaDataJson); + return client.postNewAddon( + uploadUuid, + savedIdPath, + metaDataJson, + patchData, + ); } - return client.putVersion(uploadUuid, id, metaDataJson); + return client.putVersion(uploadUuid, id, metaDataJson, patchData); } export async function saveIdToFile(filePath, id) { diff --git a/tests/unit/test-cmd/test.sign.js b/tests/unit/test-cmd/test.sign.js index b5283b1561..3b0d5a0acc 100644 --- a/tests/unit/test-cmd/test.sign.js +++ b/tests/unit/test-cmd/test.sign.js @@ -384,6 +384,23 @@ describe('sign', () => { }); })); + it('passes the uploadSourceCode parameter to submissionAPI signer as submissionSource', () => + withTempDir((tmpDir) => { + const stubs = getStubs(); + const uploadSourceCode = 'path/to/source.zip'; + return sign(tmpDir, stubs, { + extraArgs: { + uploadSourceCode, + useSubmissionApi: true, + channel: 'unlisted', + }, + }).then(() => { + sinon.assert.called(stubs.signingOptions.submitAddon); + sinon.assert.calledWithMatch(stubs.signingOptions.submitAddon, { + submissionSource: uploadSourceCode, + }); + }); + })); 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 index bfff61fea7..70850722ea 100644 --- a/tests/unit/test-util/test.submit-addon.js +++ b/tests/unit/test-util/test.submit-addon.js @@ -50,17 +50,24 @@ describe('util.submit-addon', () => { let getPreviousUuidOrUploadXpiStub; let postNewAddonStub; let putVersionStub; + let fileFromSyncStub; const uploadUuid = '{some-upload-uuid}'; + const fakeFileFromSync = new File([], 'foo.xpi'); beforeEach(() => { statStub = sinon .stub(fsPromises, 'stat') + .onFirstCall() .resolves({ isFile: () => true }); + statStub.callThrough(); getPreviousUuidOrUploadXpiStub = sinon .stub(Client.prototype, 'getPreviousUuidOrUploadXpi') .resolves(uploadUuid); postNewAddonStub = sinon.stub(Client.prototype, 'postNewAddon'); putVersionStub = sinon.stub(Client.prototype, 'putVersion'); + fileFromSyncStub = sinon + .stub(Client.prototype, 'fileFromSync') + .returns(fakeFileFromSync); }); afterEach(() => { @@ -68,6 +75,7 @@ describe('util.submit-addon', () => { getPreviousUuidOrUploadXpiStub.restore(); postNewAddonStub.restore(); putVersionStub.restore(); + fileFromSyncStub.restore(); }); const signAddonDefaults = { @@ -122,6 +130,7 @@ describe('util.submit-addon', () => { downloadDir, userAgentString, }); + sinon.assert.notCalled(fileFromSyncStub); }); it('calls postNewAddon if `id` is undefined', async () => { @@ -187,6 +196,72 @@ describe('util.submit-addon', () => { metaDataJson, ); }); + + it('includes source data to be patched if submissionSource defined for new addon', async () => { + const submissionSource = 'path/to/source/zip'; + statStub.onSecondCall().resolves({ isFile: () => true }); + await signAddon({ + ...signAddonDefaults, + submissionSource, + }); + + sinon.assert.calledWith(fileFromSyncStub, submissionSource); + sinon.assert.calledWith( + postNewAddonStub, + uploadUuid, + signAddonDefaults.savedIdPath, + {}, + { version: { source: fakeFileFromSync } }, + ); + }); + + it('includes source data to be patched if submissionSource defined for new version', async () => { + const submissionSource = 'path/to/source/zip'; + statStub.onSecondCall().resolves({ isFile: () => true }); + const id = '@thisID'; + await signAddon({ + ...signAddonDefaults, + submissionSource, + id, + }); + + sinon.assert.calledWith(fileFromSyncStub, submissionSource); + sinon.assert.calledWith( + putVersionStub, + uploadUuid, + id, + {}, + { version: { source: fakeFileFromSync } }, + ); + }); + + it('throws error if submissionSource is not found', async () => { + const submissionSource = 'path/to/source/zip'; + const signAddonPromise = signAddon({ + ...signAddonDefaults, + submissionSource, + }); + await assert.isRejected( + signAddonPromise, + `error with ${submissionSource}: ` + + 'Error: ENOENT: no such file or directory', + ); + }); + + it('throws error if submissionSource is a directory', async () => { + await withTempDir(async (tmpDir) => { + const submissionSource = path.join(tmpDir.path(), 'someDirectory'); + await fsPromises.mkdir(submissionSource); + const signAddonPromise = signAddon({ + ...signAddonDefaults, + submissionSource, + }); + await assert.isRejected( + signAddonPromise, + `error with ${submissionSource}: ` + 'Error: not a file', + ); + }); + }); }); describe('Client', () => { @@ -738,6 +813,42 @@ describe('util.submit-addon', () => { }); }); + describe('doFormDataPatch', () => { + const addonId = 'some-addon-id'; + const versionId = 123456; + const dataField1 = 'someField'; + const dataField2 = 'otherField'; + const data = { dataField1: 'value', dataField2: 0 }; + const formData = new FormData(); + formData.append(dataField1, data[dataField1]); + formData.append(dataField2, data[dataField2]); + + it('creates the url from addon and version', async () => { + const client = new Client(clientDefaults); + const fetchStub = sinon + .stub(client, 'fetch') + .resolves(new Response('', { ok: true, status: 200 })); + await client.doFormDataPatch(data, addonId, versionId); + const patchUrl = new URL( + `addon/${addonId}/versions/${versionId}/`, + client.apiUrl, + ); + + sinon.assert.calledWith(fetchStub, patchUrl, 'PATCH', formData); + }); + + it('catches and throws for non ok responses', async () => { + const client = new Client(clientDefaults); + sinon.stub(client, 'fetch').resolves(); + const response = client.doFormDataPatch(data, addonId, versionId); + + assert.isRejected( + response, + `Uploading ${dataField1}${dataField2} failed`, + ); + }); + }); + describe('waitForApproval', () => { it('aborts approval wait after timeout', async () => { const client = new Client({ @@ -902,7 +1013,13 @@ describe('util.submit-addon', () => { [{ body: sampleAddonDetail, status: 200 }], ); addApprovalMocks(versionId); - await client.postNewAddon(uploadUuid, idFile, {}, saveIdStub); + await client.postNewAddon( + uploadUuid, + idFile, + {}, + undefined, + saveIdStub, + ); sinon.assert.calledWith(saveIdStub, idFile, sampleAddonDetail.guid); }); @@ -922,16 +1039,28 @@ describe('util.submit-addon', () => { describe('doAfterSubmit', () => { const downloadUrl = 'https://a.download/url'; - let approvalStub; - let downloadStub; const newVersionId = sampleVersionDetail.id; const editUrl = sampleVersionDetail.editUrl; + const patchData = { version: { source: 'somesource' } }; + + let approvalStub; + let downloadStub; + let doFormDataPatchStub; before(() => { approvalStub = sinon .stub(client, 'waitForApproval') .resolves(downloadUrl); downloadStub = sinon.stub(client, 'downloadSignedFile').resolves(); + doFormDataPatchStub = sinon + .stub(client, 'doFormDataPatch') + .resolves(); + }); + + afterEach(() => { + approvalStub.resetHistory(); + downloadStub.resetHistory(); + doFormDataPatchStub.resetHistory(); }); it('skips download if approval timeout is 0', async () => { @@ -947,6 +1076,27 @@ describe('util.submit-addon', () => { sinon.assert.calledWith(approvalStub, addonId, newVersionId); sinon.assert.calledWith(downloadStub, new URL(downloadUrl), addonId); }); + + it('calls doFormDataPatch if patchData.version is defined', async () => { + client.approvalCheckTimeout = 0; + await client.doAfterSubmit(addonId, newVersionId, editUrl, patchData); + + sinon.assert.calledWith( + doFormDataPatchStub, + patchData.version, + addonId, + newVersionId, + ); + }); + + it('does not call doFormDataPatch is patchData.version is undefined', async () => { + client.approvalCheckTimeout = 0; + await client.doAfterSubmit(addonId, newVersionId, editUrl, { + version: undefined, + }); + + sinon.assert.notCalled(doFormDataPatchStub); + }); }); });