api-autoupdater-darwin-spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  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, ifit } 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.env.CI && 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, mutateAppPostSign?: {
  105. mutate: (appPath: string) => Promise<void>,
  106. mutationKey: string,
  107. }) => {
  108. const key = `${version}-${fixture}-${mutateAppPostSign?.mutationKey || 'no-mutation'}`;
  109. if (!cachedZips[key]) {
  110. let updateZipPath: string;
  111. await withTempDirectory(async (dir) => {
  112. const secondAppPath = await copyApp(dir, fixture);
  113. const appPJPath = path.resolve(secondAppPath, 'Contents', 'Resources', 'app', 'package.json');
  114. await fs.writeFile(
  115. appPJPath,
  116. (await fs.readFile(appPJPath, 'utf8')).replace('1.0.0', version)
  117. );
  118. await signApp(secondAppPath);
  119. await mutateAppPostSign?.mutate(secondAppPath);
  120. updateZipPath = path.resolve(dir, 'update.zip');
  121. await spawn('zip', ['-r', '--symlinks', updateZipPath, './'], {
  122. cwd: dir
  123. });
  124. }, false);
  125. cachedZips[key] = updateZipPath!;
  126. }
  127. return cachedZips[key];
  128. };
  129. after(() => {
  130. for (const version of Object.keys(cachedZips)) {
  131. cp.spawnSync('rm', ['-r', path.dirname(cachedZips[version])]);
  132. }
  133. });
  134. // On arm64 builds the built app is self-signed by default so the setFeedURL call always works
  135. ifit(process.arch !== 'arm64')('should fail to set the feed URL when the app is not signed', async () => {
  136. await withTempDirectory(async (dir) => {
  137. const appPath = await copyApp(dir);
  138. const launchResult = await launchApp(appPath, ['http://myupdate']);
  139. console.log(launchResult);
  140. expect(launchResult.code).to.equal(1);
  141. expect(launchResult.out).to.include('Could not get code signature for running application');
  142. });
  143. });
  144. it('should cleanly set the feed URL when the app is signed', async () => {
  145. await withTempDirectory(async (dir) => {
  146. const appPath = await copyApp(dir);
  147. await signApp(appPath);
  148. const launchResult = await launchApp(appPath, ['http://myupdate']);
  149. expect(launchResult.code).to.equal(0);
  150. expect(launchResult.out).to.include('Feed URL Set: http://myupdate');
  151. });
  152. });
  153. describe('with update server', () => {
  154. let port = 0;
  155. let server: express.Application = null as any;
  156. let httpServer: http.Server = null as any;
  157. let requests: express.Request[] = [];
  158. beforeEach((done) => {
  159. requests = [];
  160. server = express();
  161. server.use((req, res, next) => {
  162. requests.push(req);
  163. next();
  164. });
  165. httpServer = server.listen(0, '127.0.0.1', () => {
  166. port = (httpServer.address() as AddressInfo).port;
  167. done();
  168. });
  169. });
  170. afterEach(async () => {
  171. if (httpServer) {
  172. await new Promise<void>(resolve => {
  173. httpServer.close(() => {
  174. httpServer = null as any;
  175. server = null as any;
  176. resolve();
  177. });
  178. });
  179. }
  180. });
  181. it('should hit the update endpoint when checkForUpdates is called', async () => {
  182. await withTempDirectory(async (dir) => {
  183. const appPath = await copyApp(dir, 'check');
  184. await signApp(appPath);
  185. server.get('/update-check', (req, res) => {
  186. res.status(204).send();
  187. });
  188. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  189. logOnError(launchResult, () => {
  190. expect(launchResult.code).to.equal(0);
  191. expect(requests).to.have.lengthOf(1);
  192. expect(requests[0]).to.have.property('url', '/update-check');
  193. expect(requests[0].header('user-agent')).to.include('Electron/');
  194. });
  195. });
  196. });
  197. it('should hit the update endpoint with customer headers when checkForUpdates is called', async () => {
  198. await withTempDirectory(async (dir) => {
  199. const appPath = await copyApp(dir, 'check-with-headers');
  200. await signApp(appPath);
  201. server.get('/update-check', (req, res) => {
  202. res.status(204).send();
  203. });
  204. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  205. logOnError(launchResult, () => {
  206. expect(launchResult.code).to.equal(0);
  207. expect(requests).to.have.lengthOf(1);
  208. expect(requests[0]).to.have.property('url', '/update-check');
  209. expect(requests[0].header('x-test')).to.equal('this-is-a-test');
  210. });
  211. });
  212. });
  213. it('should hit the download endpoint when an update is available and error if the file is bad', async () => {
  214. await withTempDirectory(async (dir) => {
  215. const appPath = await copyApp(dir, 'update');
  216. await signApp(appPath);
  217. server.get('/update-file', (req, res) => {
  218. res.status(500).send('This is not a file');
  219. });
  220. server.get('/update-check', (req, res) => {
  221. res.json({
  222. url: `http://localhost:${port}/update-file`,
  223. name: 'My Release Name',
  224. notes: 'Theses are some release notes innit',
  225. pub_date: (new Date()).toString()
  226. });
  227. });
  228. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  229. logOnError(launchResult, () => {
  230. expect(launchResult).to.have.property('code', 1);
  231. expect(launchResult.out).to.include('Update download failed. The server sent an invalid response.');
  232. expect(requests).to.have.lengthOf(2);
  233. expect(requests[0]).to.have.property('url', '/update-check');
  234. expect(requests[1]).to.have.property('url', '/update-file');
  235. expect(requests[0].header('user-agent')).to.include('Electron/');
  236. expect(requests[1].header('user-agent')).to.include('Electron/');
  237. });
  238. });
  239. });
  240. const withUpdatableApp = async (opts: {
  241. nextVersion: string;
  242. startFixture: string;
  243. endFixture: string;
  244. mutateAppPostSign?: {
  245. mutate: (appPath: string) => Promise<void>,
  246. mutationKey: string,
  247. }
  248. }, fn: (appPath: string, zipPath: string) => Promise<void>) => {
  249. await withTempDirectory(async (dir) => {
  250. const appPath = await copyApp(dir, opts.startFixture);
  251. await signApp(appPath);
  252. const updateZipPath = await getOrCreateUpdateZipPath(opts.nextVersion, opts.endFixture, opts.mutateAppPostSign);
  253. await fn(appPath, updateZipPath);
  254. });
  255. };
  256. it('should hit the download endpoint when an update is available and update successfully when the zip is provided', async () => {
  257. await withUpdatableApp({
  258. nextVersion: '2.0.0',
  259. startFixture: 'update',
  260. endFixture: 'update'
  261. }, async (appPath, updateZipPath) => {
  262. server.get('/update-file', (req, res) => {
  263. res.download(updateZipPath);
  264. });
  265. server.get('/update-check', (req, res) => {
  266. res.json({
  267. url: `http://localhost:${port}/update-file`,
  268. name: 'My Release Name',
  269. notes: 'Theses are some release notes innit',
  270. pub_date: (new Date()).toString()
  271. });
  272. });
  273. const relaunchPromise = new Promise<void>((resolve) => {
  274. server.get('/update-check/updated/:version', (req, res) => {
  275. res.status(204).send();
  276. resolve();
  277. });
  278. });
  279. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  280. logOnError(launchResult, () => {
  281. expect(launchResult).to.have.property('code', 0);
  282. expect(launchResult.out).to.include('Update Downloaded');
  283. expect(requests).to.have.lengthOf(2);
  284. expect(requests[0]).to.have.property('url', '/update-check');
  285. expect(requests[1]).to.have.property('url', '/update-file');
  286. expect(requests[0].header('user-agent')).to.include('Electron/');
  287. expect(requests[1].header('user-agent')).to.include('Electron/');
  288. });
  289. await relaunchPromise;
  290. expect(requests).to.have.lengthOf(3);
  291. expect(requests[2].url).to.equal('/update-check/updated/2.0.0');
  292. expect(requests[2].header('user-agent')).to.include('Electron/');
  293. });
  294. });
  295. it('should hit the download endpoint when an update is available and fail when the zip signature is invalid', async () => {
  296. await withUpdatableApp({
  297. nextVersion: '2.0.0',
  298. startFixture: 'update',
  299. endFixture: 'update',
  300. mutateAppPostSign: {
  301. mutationKey: 'add-resource',
  302. mutate: async (appPath) => {
  303. const resourcesPath = path.resolve(appPath, 'Contents', 'Resources', 'app', 'injected.txt');
  304. await fs.writeFile(resourcesPath, 'demo');
  305. }
  306. }
  307. }, async (appPath, updateZipPath) => {
  308. server.get('/update-file', (req, res) => {
  309. res.download(updateZipPath);
  310. });
  311. server.get('/update-check', (req, res) => {
  312. res.json({
  313. url: `http://localhost:${port}/update-file`,
  314. name: 'My Release Name',
  315. notes: 'Theses are some release notes innit',
  316. pub_date: (new Date()).toString()
  317. });
  318. });
  319. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  320. logOnError(launchResult, () => {
  321. expect(launchResult).to.have.property('code', 1);
  322. expect(launchResult.out).to.include('Code signature at URL');
  323. expect(launchResult.out).to.include('a sealed resource is missing or invalid');
  324. expect(requests).to.have.lengthOf(2);
  325. expect(requests[0]).to.have.property('url', '/update-check');
  326. expect(requests[1]).to.have.property('url', '/update-file');
  327. expect(requests[0].header('user-agent')).to.include('Electron/');
  328. expect(requests[1].header('user-agent')).to.include('Electron/');
  329. });
  330. });
  331. });
  332. it('should hit the download endpoint when an update is available and fail when the ShipIt binary is a symlink', async () => {
  333. await withUpdatableApp({
  334. nextVersion: '2.0.0',
  335. startFixture: 'update',
  336. endFixture: 'update',
  337. mutateAppPostSign: {
  338. mutationKey: 'modify-shipit',
  339. mutate: async (appPath) => {
  340. const shipItPath = path.resolve(appPath, 'Contents', 'Frameworks', 'Squirrel.framework', 'Resources', 'ShipIt');
  341. await fs.remove(shipItPath);
  342. await fs.symlink('/tmp/ShipIt', shipItPath, 'file');
  343. }
  344. }
  345. }, async (appPath, updateZipPath) => {
  346. server.get('/update-file', (req, res) => {
  347. res.download(updateZipPath);
  348. });
  349. server.get('/update-check', (req, res) => {
  350. res.json({
  351. url: `http://localhost:${port}/update-file`,
  352. name: 'My Release Name',
  353. notes: 'Theses are some release notes innit',
  354. pub_date: (new Date()).toString()
  355. });
  356. });
  357. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  358. logOnError(launchResult, () => {
  359. expect(launchResult).to.have.property('code', 1);
  360. expect(launchResult.out).to.include('Code signature at URL');
  361. expect(launchResult.out).to.include('a sealed resource is missing or invalid');
  362. expect(requests).to.have.lengthOf(2);
  363. expect(requests[0]).to.have.property('url', '/update-check');
  364. expect(requests[1]).to.have.property('url', '/update-file');
  365. expect(requests[0].header('user-agent')).to.include('Electron/');
  366. expect(requests[1].header('user-agent')).to.include('Electron/');
  367. });
  368. });
  369. });
  370. it('should hit the download endpoint when an update is available and fail when the Electron Framework is modified', async () => {
  371. await withUpdatableApp({
  372. nextVersion: '2.0.0',
  373. startFixture: 'update',
  374. endFixture: 'update',
  375. mutateAppPostSign: {
  376. mutationKey: 'modify-eframework',
  377. mutate: async (appPath) => {
  378. const shipItPath = path.resolve(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework', 'Electron Framework');
  379. await fs.appendFile(shipItPath, Buffer.from('123'));
  380. }
  381. }
  382. }, async (appPath, updateZipPath) => {
  383. server.get('/update-file', (req, res) => {
  384. res.download(updateZipPath);
  385. });
  386. server.get('/update-check', (req, res) => {
  387. res.json({
  388. url: `http://localhost:${port}/update-file`,
  389. name: 'My Release Name',
  390. notes: 'Theses are some release notes innit',
  391. pub_date: (new Date()).toString()
  392. });
  393. });
  394. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  395. logOnError(launchResult, () => {
  396. expect(launchResult).to.have.property('code', 1);
  397. expect(launchResult.out).to.include('Code signature at URL');
  398. expect(launchResult.out).to.include(' main executable failed strict validation');
  399. expect(requests).to.have.lengthOf(2);
  400. expect(requests[0]).to.have.property('url', '/update-check');
  401. expect(requests[1]).to.have.property('url', '/update-file');
  402. expect(requests[0].header('user-agent')).to.include('Electron/');
  403. expect(requests[1].header('user-agent')).to.include('Electron/');
  404. });
  405. });
  406. });
  407. it('should hit the download endpoint when an update is available and update successfully when the zip is provided with JSON update mode', async () => {
  408. await withUpdatableApp({
  409. nextVersion: '2.0.0',
  410. startFixture: 'update-json',
  411. endFixture: 'update-json'
  412. }, async (appPath, updateZipPath) => {
  413. server.get('/update-file', (req, res) => {
  414. res.download(updateZipPath);
  415. });
  416. server.get('/update-check', (req, res) => {
  417. res.json({
  418. currentRelease: '2.0.0',
  419. releases: [
  420. {
  421. version: '2.0.0',
  422. updateTo: {
  423. version: '2.0.0',
  424. url: `http://localhost:${port}/update-file`,
  425. name: 'My Release Name',
  426. notes: 'Theses are some release notes innit',
  427. pub_date: (new Date()).toString()
  428. }
  429. }
  430. ]
  431. });
  432. });
  433. const relaunchPromise = new Promise<void>((resolve) => {
  434. server.get('/update-check/updated/:version', (req, res) => {
  435. res.status(204).send();
  436. resolve();
  437. });
  438. });
  439. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  440. logOnError(launchResult, () => {
  441. expect(launchResult).to.have.property('code', 0);
  442. expect(launchResult.out).to.include('Update Downloaded');
  443. expect(requests).to.have.lengthOf(2);
  444. expect(requests[0]).to.have.property('url', '/update-check');
  445. expect(requests[1]).to.have.property('url', '/update-file');
  446. expect(requests[0].header('user-agent')).to.include('Electron/');
  447. expect(requests[1].header('user-agent')).to.include('Electron/');
  448. });
  449. await relaunchPromise;
  450. expect(requests).to.have.lengthOf(3);
  451. expect(requests[2]).to.have.property('url', '/update-check/updated/2.0.0');
  452. expect(requests[2].header('user-agent')).to.include('Electron/');
  453. });
  454. });
  455. 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 () => {
  456. await withUpdatableApp({
  457. nextVersion: '0.1.0',
  458. startFixture: 'update-json',
  459. endFixture: 'update-json'
  460. }, async (appPath, updateZipPath) => {
  461. server.get('/update-file', (req, res) => {
  462. res.download(updateZipPath);
  463. });
  464. server.get('/update-check', (req, res) => {
  465. res.json({
  466. currentRelease: '0.1.0',
  467. releases: [
  468. {
  469. version: '0.1.0',
  470. updateTo: {
  471. version: '0.1.0',
  472. url: `http://localhost:${port}/update-file`,
  473. name: 'My Release Name',
  474. notes: 'Theses are some release notes innit',
  475. pub_date: (new Date()).toString()
  476. }
  477. }
  478. ]
  479. });
  480. });
  481. const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
  482. logOnError(launchResult, () => {
  483. expect(launchResult).to.have.property('code', 1);
  484. expect(launchResult.out).to.include('No update available');
  485. expect(requests).to.have.lengthOf(1);
  486. expect(requests[0]).to.have.property('url', '/update-check');
  487. expect(requests[0].header('user-agent')).to.include('Electron/');
  488. });
  489. });
  490. });
  491. });
  492. });