api-autoupdater-darwin-spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import { expect } from 'chai';
  2. import * as cp from 'child_process';
  3. import * as http from 'http';
  4. import * as express from 'express';
  5. import * as fs from 'fs-extra';
  6. import * as os from 'os';
  7. import * as path from 'path';
  8. import { AddressInfo } from 'net';
  9. import { ifdescribe } from './spec-helpers';
  10. const features = process._linkedBinding('electron_common_features');
  11. const fixturesPath = path.resolve(__dirname, 'fixtures');
  12. // We can only test the auto updater on darwin non-component builds
  13. ifdescribe(process.platform === 'darwin' && process.arch !== 'arm64' && !process.mas && !features.isComponentBuild())('autoUpdater behavior', function () {
  14. this.timeout(120000);
  15. let identity = '';
  16. beforeEach(function () {
  17. const result = cp.spawnSync(path.resolve(__dirname, '../script/codesign/get-trusted-identity.sh'));
  18. if (result.status !== 0 || result.stdout.toString().trim().length === 0) {
  19. // Per https://circleci.com/docs/2.0/env-vars:
  20. // CIRCLE_PR_NUMBER is only present on forked PRs
  21. if (process.env.CI && !process.env.CIRCLE_PR_NUMBER) {
  22. throw new Error('No valid signing identity available to run autoUpdater specs');
  23. }
  24. this.skip();
  25. } else {
  26. identity = result.stdout.toString().trim();
  27. }
  28. });
  29. it('should have a valid code signing identity', () => {
  30. expect(identity).to.be.a('string').with.lengthOf.at.least(1);
  31. });
  32. const copyApp = async (newDir: string, fixture = 'initial') => {
  33. const appBundlePath = path.resolve(process.execPath, '../../..');
  34. const newPath = path.resolve(newDir, 'Electron.app');
  35. cp.spawnSync('cp', ['-R', appBundlePath, path.dirname(newPath)]);
  36. const appDir = path.resolve(newPath, 'Contents/Resources/app');
  37. await fs.mkdirp(appDir);
  38. await fs.copy(path.resolve(fixturesPath, 'auto-update', fixture), appDir);
  39. const plistPath = path.resolve(newPath, 'Contents', 'Info.plist');
  40. await fs.writeFile(
  41. plistPath,
  42. (await fs.readFile(plistPath, 'utf8')).replace('<key>BuildMachineOSBuild</key>', `<key>NSAppTransportSecurity</key>
  43. <dict>
  44. <key>NSAllowsArbitraryLoads</key>
  45. <true/>
  46. <key>NSExceptionDomains</key>
  47. <dict>
  48. <key>localhost</key>
  49. <dict>
  50. <key>NSExceptionAllowsInsecureHTTPLoads</key>
  51. <true/>
  52. <key>NSIncludesSubdomains</key>
  53. <true/>
  54. </dict>
  55. </dict>
  56. </dict><key>BuildMachineOSBuild</key>`)
  57. );
  58. return newPath;
  59. };
  60. const spawn = (cmd: string, args: string[], opts: any = {}) => {
  61. let out = '';
  62. const child = cp.spawn(cmd, args, opts);
  63. child.stdout.on('data', (chunk: Buffer) => {
  64. out += chunk.toString();
  65. });
  66. child.stderr.on('data', (chunk: Buffer) => {
  67. out += chunk.toString();
  68. });
  69. return new Promise<{ code: number, out: string }>((resolve) => {
  70. child.on('exit', (code, signal) => {
  71. expect(signal).to.equal(null);
  72. resolve({
  73. code: code!,
  74. out
  75. });
  76. });
  77. });
  78. };
  79. const signApp = (appPath: string) => {
  80. return spawn('codesign', ['-s', identity, '--deep', '--force', appPath]);
  81. };
  82. const launchApp = (appPath: string, args: string[] = []) => {
  83. return spawn(path.resolve(appPath, 'Contents/MacOS/Electron'), args);
  84. };
  85. const withTempDirectory = async (fn: (dir: string) => Promise<void>, autoCleanUp = true) => {
  86. const dir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-update-spec-'));
  87. try {
  88. await fn(dir);
  89. } finally {
  90. if (autoCleanUp) {
  91. cp.spawnSync('rm', ['-r', dir]);
  92. }
  93. }
  94. };
  95. const logOnError = (what: any, fn: () => void) => {
  96. try {
  97. fn();
  98. } catch (err) {
  99. console.error(what);
  100. throw err;
  101. }
  102. };
  103. const cachedZips: Record<string, string> = {};
  104. const getOrCreateUpdateZipPath = async (version: string, fixture: string) => {
  105. const key = `${version}-${fixture}`;
  106. if (!cachedZips[key]) {
  107. let updateZipPath: string;
  108. await withTempDirectory(async (dir) => {
  109. const secondAppPath = await copyApp(dir, fixture);
  110. const appPJPath = path.resolve(secondAppPath, 'Contents', 'Resources', 'app', 'package.json');
  111. await fs.writeFile(
  112. appPJPath,
  113. (await fs.readFile(appPJPath, 'utf8')).replace('1.0.0', version)
  114. );
  115. await signApp(secondAppPath);
  116. updateZipPath = path.resolve(dir, 'update.zip');
  117. await spawn('zip', ['-r', '--symlinks', updateZipPath, './'], {
  118. cwd: dir
  119. });
  120. }, false);
  121. cachedZips[key] = updateZipPath!;
  122. }
  123. return cachedZips[key];
  124. };
  125. after(() => {
  126. for (const version of Object.keys(cachedZips)) {
  127. cp.spawnSync('rm', ['-r', path.dirname(cachedZips[version])]);
  128. }
  129. });
  130. it('should fail to set the feed URL when the app is not signed', async () => {
  131. await withTempDirectory(async (dir) => {
  132. const appPath = await copyApp(dir);
  133. const launchResult = await launchApp(appPath, ['http://myupdate']);
  134. expect(launchResult.code).to.equal(1);
  135. expect(launchResult.out).to.include('Could not get code signature for running application');
  136. });
  137. });
  138. it('should cleanly set the feed URL when the app is signed', async () => {
  139. await withTempDirectory(async (dir) => {
  140. const appPath = await copyApp(dir);
  141. await signApp(appPath);
  142. const launchResult = await launchApp(appPath, ['http://myupdate']);
  143. expect(launchResult.code).to.equal(0);
  144. expect(launchResult.out).to.include('Feed URL Set: http://myupdate');
  145. });
  146. });
  147. describe('with update server', () => {
  148. let port = 0;
  149. let server: express.Application = null as any;
  150. let httpServer: http.Server = null as any;
  151. let requests: express.Request[] = [];
  152. beforeEach((done) => {
  153. requests = [];
  154. server = express();
  155. server.use((req, res, next) => {
  156. requests.push(req);
  157. next();
  158. });
  159. httpServer = server.listen(0, '127.0.0.1', () => {
  160. port = (httpServer.address() as AddressInfo).port;
  161. done();
  162. });
  163. });
  164. afterEach(async () => {
  165. if (httpServer) {
  166. await new Promise(resolve => {
  167. httpServer.close(() => {
  168. httpServer = null as any;
  169. server = null as any;
  170. resolve();
  171. });
  172. });
  173. }
  174. });
  175. it('should hit the update endpoint when checkForUpdates is called', async () => {
  176. await withTempDirectory(async (dir) => {
  177. const appPath = await copyApp(dir, 'check');
  178. await signApp(appPath);
  179. server.get('/update-check', (req, res) => {
  180. res.status(204).send();
  181. });
  182. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  183. logOnError(launchResult, () => {
  184. expect(launchResult.code).to.equal(0);
  185. expect(requests).to.have.lengthOf(1);
  186. expect(requests[0]).to.have.property('url', '/update-check');
  187. expect(requests[0].header('user-agent')).to.include('Electron/');
  188. });
  189. });
  190. });
  191. it('should hit the update endpoint with customer headers when checkForUpdates is called', async () => {
  192. await withTempDirectory(async (dir) => {
  193. const appPath = await copyApp(dir, 'check-with-headers');
  194. await signApp(appPath);
  195. server.get('/update-check', (req, res) => {
  196. res.status(204).send();
  197. });
  198. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  199. logOnError(launchResult, () => {
  200. expect(launchResult.code).to.equal(0);
  201. expect(requests).to.have.lengthOf(1);
  202. expect(requests[0]).to.have.property('url', '/update-check');
  203. expect(requests[0].header('x-test')).to.equal('this-is-a-test');
  204. });
  205. });
  206. });
  207. it('should hit the download endpoint when an update is available and error if the file is bad', async () => {
  208. await withTempDirectory(async (dir) => {
  209. const appPath = await copyApp(dir, 'update');
  210. await signApp(appPath);
  211. server.get('/update-file', (req, res) => {
  212. res.status(500).send('This is not a file');
  213. });
  214. server.get('/update-check', (req, res) => {
  215. res.json({
  216. url: `http://localhost:${port}/update-file`,
  217. name: 'My Release Name',
  218. notes: 'Theses are some release notes innit',
  219. pub_date: (new Date()).toString()
  220. });
  221. });
  222. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  223. logOnError(launchResult, () => {
  224. expect(launchResult).to.have.property('code', 1);
  225. expect(launchResult.out).to.include('Update download failed. The server sent an invalid response.');
  226. expect(requests).to.have.lengthOf(2);
  227. expect(requests[0]).to.have.property('url', '/update-check');
  228. expect(requests[1]).to.have.property('url', '/update-file');
  229. expect(requests[0].header('user-agent')).to.include('Electron/');
  230. expect(requests[1].header('user-agent')).to.include('Electron/');
  231. });
  232. });
  233. });
  234. const withUpdatableApp = async (opts: {
  235. nextVersion: string;
  236. startFixture: string;
  237. endFixture: string;
  238. }, fn: (appPath: string, zipPath: string) => Promise<void>) => {
  239. await withTempDirectory(async (dir) => {
  240. const appPath = await copyApp(dir, opts.startFixture);
  241. await signApp(appPath);
  242. const updateZipPath = await getOrCreateUpdateZipPath(opts.nextVersion, opts.endFixture);
  243. await fn(appPath, updateZipPath);
  244. });
  245. };
  246. it('should hit the download endpoint when an update is available and update successfully when the zip is provided', async () => {
  247. await withUpdatableApp({
  248. nextVersion: '2.0.0',
  249. startFixture: 'update',
  250. endFixture: 'update'
  251. }, async (appPath, updateZipPath) => {
  252. server.get('/update-file', (req, res) => {
  253. res.download(updateZipPath);
  254. });
  255. server.get('/update-check', (req, res) => {
  256. res.json({
  257. url: `http://localhost:${port}/update-file`,
  258. name: 'My Release Name',
  259. notes: 'Theses are some release notes innit',
  260. pub_date: (new Date()).toString()
  261. });
  262. });
  263. const relaunchPromise = new Promise((resolve) => {
  264. server.get('/update-check/updated/:version', (req, res) => {
  265. res.status(204).send();
  266. resolve();
  267. });
  268. });
  269. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  270. logOnError(launchResult, () => {
  271. expect(launchResult).to.have.property('code', 0);
  272. expect(launchResult.out).to.include('Update Downloaded');
  273. expect(requests).to.have.lengthOf(2);
  274. expect(requests[0]).to.have.property('url', '/update-check');
  275. expect(requests[1]).to.have.property('url', '/update-file');
  276. expect(requests[0].header('user-agent')).to.include('Electron/');
  277. expect(requests[1].header('user-agent')).to.include('Electron/');
  278. });
  279. await relaunchPromise;
  280. expect(requests).to.have.lengthOf(3);
  281. expect(requests[2].url).to.equal('/update-check/updated/2.0.0');
  282. expect(requests[2].header('user-agent')).to.include('Electron/');
  283. });
  284. });
  285. it('should hit the download endpoint when an update is available and update successfully when the zip is provided with JSON update mode', async () => {
  286. await withUpdatableApp({
  287. nextVersion: '2.0.0',
  288. startFixture: 'update-json',
  289. endFixture: 'update-json'
  290. }, async (appPath, updateZipPath) => {
  291. server.get('/update-file', (req, res) => {
  292. res.download(updateZipPath);
  293. });
  294. server.get('/update-check', (req, res) => {
  295. res.json({
  296. currentRelease: '2.0.0',
  297. releases: [
  298. {
  299. version: '2.0.0',
  300. updateTo: {
  301. version: '2.0.0',
  302. url: `http://localhost:${port}/update-file`,
  303. name: 'My Release Name',
  304. notes: 'Theses are some release notes innit',
  305. pub_date: (new Date()).toString()
  306. }
  307. }
  308. ]
  309. });
  310. });
  311. const relaunchPromise = new Promise((resolve) => {
  312. server.get('/update-check/updated/:version', (req, res) => {
  313. res.status(204).send();
  314. resolve();
  315. });
  316. });
  317. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  318. logOnError(launchResult, () => {
  319. expect(launchResult).to.have.property('code', 0);
  320. expect(launchResult.out).to.include('Update Downloaded');
  321. expect(requests).to.have.lengthOf(2);
  322. expect(requests[0]).to.have.property('url', '/update-check');
  323. expect(requests[1]).to.have.property('url', '/update-file');
  324. expect(requests[0].header('user-agent')).to.include('Electron/');
  325. expect(requests[1].header('user-agent')).to.include('Electron/');
  326. });
  327. await relaunchPromise;
  328. expect(requests).to.have.lengthOf(3);
  329. expect(requests[2]).to.have.property('url', '/update-check/updated/2.0.0');
  330. expect(requests[2].header('user-agent')).to.include('Electron/');
  331. });
  332. });
  333. it('should hit the download endpoint when an update is available and not update in JSON update mode when the currentRelease is older than the current version', async () => {
  334. await withUpdatableApp({
  335. nextVersion: '0.1.0',
  336. startFixture: 'update-json',
  337. endFixture: 'update-json'
  338. }, async (appPath, updateZipPath) => {
  339. server.get('/update-file', (req, res) => {
  340. res.download(updateZipPath);
  341. });
  342. server.get('/update-check', (req, res) => {
  343. res.json({
  344. currentRelease: '0.1.0',
  345. releases: [
  346. {
  347. version: '0.1.0',
  348. updateTo: {
  349. version: '0.1.0',
  350. url: `http://localhost:${port}/update-file`,
  351. name: 'My Release Name',
  352. notes: 'Theses are some release notes innit',
  353. pub_date: (new Date()).toString()
  354. }
  355. }
  356. ]
  357. });
  358. });
  359. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  360. logOnError(launchResult, () => {
  361. expect(launchResult).to.have.property('code', 1);
  362. expect(launchResult.out).to.include('No update available');
  363. expect(requests).to.have.lengthOf(1);
  364. expect(requests[0]).to.have.property('url', '/update-check');
  365. expect(requests[0].header('user-agent')).to.include('Electron/');
  366. });
  367. });
  368. });
  369. });
  370. });