electron/spec-main/api-autoupdater-darwin-spec.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

527 lines
20 KiB
TypeScript
Raw Normal View History

import { expect } from 'chai';
import * as cp from 'child_process';
import * as http from 'http';
import * as express from 'express';
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
2019-11-01 20:37:02 +00:00
import { AddressInfo } from 'net';
import { ifdescribe, ifit } from './spec-helpers';
const features = process._linkedBinding('electron_common_features');
const fixturesPath = path.resolve(__dirname, 'fixtures');
// We can only test the auto updater on darwin non-component builds
ifdescribe(process.platform === 'darwin' && !(process.env.CI && process.arch === 'arm64') && !process.mas && !features.isComponentBuild())('autoUpdater behavior', function () {
this.timeout(120000);
let identity = '';
beforeEach(function () {
const result = cp.spawnSync(path.resolve(__dirname, '../script/codesign/get-trusted-identity.sh'));
2019-11-01 20:37:02 +00:00
if (result.status !== 0 || result.stdout.toString().trim().length === 0) {
// Per https://circleci.com/docs/2.0/env-vars:
// CIRCLE_PR_NUMBER is only present on forked PRs
2019-10-30 23:38:21 +00:00
if (process.env.CI && !process.env.CIRCLE_PR_NUMBER) {
throw new Error('No valid signing identity available to run autoUpdater specs');
}
this.skip();
} else {
identity = result.stdout.toString().trim();
}
});
it('should have a valid code signing identity', () => {
expect(identity).to.be.a('string').with.lengthOf.at.least(1);
});
const copyApp = async (newDir: string, fixture = 'initial') => {
const appBundlePath = path.resolve(process.execPath, '../../..');
const newPath = path.resolve(newDir, 'Electron.app');
cp.spawnSync('cp', ['-R', appBundlePath, path.dirname(newPath)]);
const appDir = path.resolve(newPath, 'Contents/Resources/app');
await fs.mkdirp(appDir);
await fs.copy(path.resolve(fixturesPath, 'auto-update', fixture), appDir);
const plistPath = path.resolve(newPath, 'Contents', 'Info.plist');
await fs.writeFile(
plistPath,
(await fs.readFile(plistPath, 'utf8')).replace('<key>BuildMachineOSBuild</key>', `<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict><key>BuildMachineOSBuild</key>`)
);
return newPath;
};
const spawn = (cmd: string, args: string[], opts: any = {}) => {
let out = '';
const child = cp.spawn(cmd, args, opts);
child.stdout.on('data', (chunk: Buffer) => {
out += chunk.toString();
});
child.stderr.on('data', (chunk: Buffer) => {
out += chunk.toString();
});
return new Promise<{ code: number, out: string }>((resolve) => {
child.on('exit', (code, signal) => {
expect(signal).to.equal(null);
resolve({
code: code!,
out
});
});
});
};
const signApp = (appPath: string) => {
return spawn('codesign', ['-s', identity, '--deep', '--force', appPath]);
};
const launchApp = (appPath: string, args: string[] = []) => {
return spawn(path.resolve(appPath, 'Contents/MacOS/Electron'), args);
};
const withTempDirectory = async (fn: (dir: string) => Promise<void>, autoCleanUp = true) => {
const dir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-update-spec-'));
try {
await fn(dir);
} finally {
if (autoCleanUp) {
cp.spawnSync('rm', ['-r', dir]);
}
}
};
const logOnError = (what: any, fn: () => void) => {
try {
fn();
} catch (err) {
console.error(what);
throw err;
}
};
const cachedZips: Record<string, string> = {};
const getOrCreateUpdateZipPath = async (version: string, fixture: string, mutateAppPostSign?: {
mutate: (appPath: string) => Promise<void>,
mutationKey: string,
}) => {
const key = `${version}-${fixture}-${mutateAppPostSign?.mutationKey || 'no-mutation'}`;
if (!cachedZips[key]) {
let updateZipPath: string;
await withTempDirectory(async (dir) => {
const secondAppPath = await copyApp(dir, fixture);
const appPJPath = path.resolve(secondAppPath, 'Contents', 'Resources', 'app', 'package.json');
await fs.writeFile(
appPJPath,
(await fs.readFile(appPJPath, 'utf8')).replace('1.0.0', version)
);
await signApp(secondAppPath);
await mutateAppPostSign?.mutate(secondAppPath);
updateZipPath = path.resolve(dir, 'update.zip');
await spawn('zip', ['-r', '--symlinks', updateZipPath, './'], {
cwd: dir
});
}, false);
cachedZips[key] = updateZipPath!;
}
return cachedZips[key];
};
after(() => {
for (const version of Object.keys(cachedZips)) {
cp.spawnSync('rm', ['-r', path.dirname(cachedZips[version])]);
}
});
// On arm64 builds the built app is self-signed by default so the setFeedURL call always works
ifit(process.arch !== 'arm64')('should fail to set the feed URL when the app is not signed', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir);
const launchResult = await launchApp(appPath, ['http://myupdate']);
console.log(launchResult);
expect(launchResult.code).to.equal(1);
expect(launchResult.out).to.include('Could not get code signature for running application');
});
});
it('should cleanly set the feed URL when the app is signed', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir);
await signApp(appPath);
const launchResult = await launchApp(appPath, ['http://myupdate']);
expect(launchResult.code).to.equal(0);
expect(launchResult.out).to.include('Feed URL Set: http://myupdate');
});
});
describe('with update server', () => {
2019-11-01 20:37:02 +00:00
let port = 0;
let server: express.Application = null as any;
let httpServer: http.Server = null as any;
2019-11-01 20:37:02 +00:00
let requests: express.Request[] = [];
beforeEach((done) => {
requests = [];
server = express();
server.use((req, res, next) => {
requests.push(req);
next();
});
httpServer = server.listen(0, '127.0.0.1', () => {
port = (httpServer.address() as AddressInfo).port;
done();
});
});
afterEach(async () => {
if (httpServer) {
await new Promise<void>(resolve => {
httpServer.close(() => {
httpServer = null as any;
server = null as any;
resolve();
});
});
}
});
it('should hit the update endpoint when checkForUpdates is called', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir, 'check');
await signApp(appPath);
server.get('/update-check', (req, res) => {
res.status(204).send();
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(requests).to.have.lengthOf(1);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[0].header('user-agent')).to.include('Electron/');
});
});
});
it('should hit the update endpoint with customer headers when checkForUpdates is called', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir, 'check-with-headers');
await signApp(appPath);
server.get('/update-check', (req, res) => {
res.status(204).send();
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult.code).to.equal(0);
expect(requests).to.have.lengthOf(1);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[0].header('x-test')).to.equal('this-is-a-test');
});
});
});
it('should hit the download endpoint when an update is available and error if the file is bad', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir, 'update');
await signApp(appPath);
server.get('/update-file', (req, res) => {
res.status(500).send('This is not a file');
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 1);
expect(launchResult.out).to.include('Update download failed. The server sent an invalid response.');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
});
});
const withUpdatableApp = async (opts: {
nextVersion: string;
startFixture: string;
endFixture: string;
mutateAppPostSign?: {
mutate: (appPath: string) => Promise<void>,
mutationKey: string,
}
}, fn: (appPath: string, zipPath: string) => Promise<void>) => {
await withTempDirectory(async (dir) => {
const appPath = await copyApp(dir, opts.startFixture);
await signApp(appPath);
const updateZipPath = await getOrCreateUpdateZipPath(opts.nextVersion, opts.endFixture, opts.mutateAppPostSign);
await fn(appPath, updateZipPath);
});
};
it('should hit the download endpoint when an update is available and update successfully when the zip is provided', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update',
endFixture: 'update'
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const relaunchPromise = new Promise<void>((resolve) => {
server.get('/update-check/updated/:version', (req, res) => {
res.status(204).send();
resolve();
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 0);
expect(launchResult.out).to.include('Update Downloaded');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
await relaunchPromise;
expect(requests).to.have.lengthOf(3);
expect(requests[2].url).to.equal('/update-check/updated/2.0.0');
expect(requests[2].header('user-agent')).to.include('Electron/');
});
});
it('should hit the download endpoint when an update is available and fail when the zip signature is invalid', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update',
endFixture: 'update',
mutateAppPostSign: {
mutationKey: 'add-resource',
mutate: async (appPath) => {
const resourcesPath = path.resolve(appPath, 'Contents', 'Resources', 'app', 'injected.txt');
await fs.writeFile(resourcesPath, 'demo');
}
}
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 1);
expect(launchResult.out).to.include('Code signature at URL');
expect(launchResult.out).to.include('a sealed resource is missing or invalid');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
});
});
it('should hit the download endpoint when an update is available and fail when the ShipIt binary is a symlink', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update',
endFixture: 'update',
mutateAppPostSign: {
mutationKey: 'modify-shipit',
mutate: async (appPath) => {
const shipItPath = path.resolve(appPath, 'Contents', 'Frameworks', 'Squirrel.framework', 'Resources', 'ShipIt');
await fs.remove(shipItPath);
await fs.symlink('/tmp/ShipIt', shipItPath, 'file');
}
}
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 1);
expect(launchResult.out).to.include('Code signature at URL');
expect(launchResult.out).to.include('a sealed resource is missing or invalid');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
});
});
it('should hit the download endpoint when an update is available and fail when the Electron Framework is modified', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update',
endFixture: 'update',
mutateAppPostSign: {
mutationKey: 'modify-eframework',
mutate: async (appPath) => {
const shipItPath = path.resolve(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework', 'Electron Framework');
await fs.appendFile(shipItPath, Buffer.from('123'));
}
}
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 1);
expect(launchResult.out).to.include('Code signature at URL');
expect(launchResult.out).to.include(' main executable failed strict validation');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
});
});
it('should hit the download endpoint when an update is available and update successfully when the zip is provided with JSON update mode', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update-json',
endFixture: 'update-json'
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
currentRelease: '2.0.0',
releases: [
{
version: '2.0.0',
updateTo: {
version: '2.0.0',
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
}
}
]
});
});
const relaunchPromise = new Promise<void>((resolve) => {
server.get('/update-check/updated/:version', (req, res) => {
res.status(204).send();
resolve();
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 0);
expect(launchResult.out).to.include('Update Downloaded');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
await relaunchPromise;
expect(requests).to.have.lengthOf(3);
expect(requests[2]).to.have.property('url', '/update-check/updated/2.0.0');
expect(requests[2].header('user-agent')).to.include('Electron/');
});
});
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 () => {
await withUpdatableApp({
nextVersion: '0.1.0',
startFixture: 'update-json',
endFixture: 'update-json'
}, async (appPath, updateZipPath) => {
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
currentRelease: '0.1.0',
releases: [
{
version: '0.1.0',
updateTo: {
version: '0.1.0',
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
}
}
]
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 1);
expect(launchResult.out).to.include('No update available');
expect(requests).to.have.lengthOf(1);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[0].header('user-agent')).to.include('Electron/');
});
});
});
});
});