feat(KAN-65): add signed macOS updates and DMG distribution
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Successful in 7m2s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m35s
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Successful in 7m2s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m35s
This commit is contained in:
@@ -4,12 +4,14 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { updateSource } from '../update-manifest.mjs';
|
||||
import {
|
||||
packageNotarizedApp,
|
||||
releaseMetadata,
|
||||
@@ -22,6 +24,8 @@ function fixture(t) {
|
||||
mkdirSync(join(directory, 'src-tauri'));
|
||||
const config = {
|
||||
version: '1.2.3',
|
||||
plugins: { updater: { pubkey: updateSource.publicKey } },
|
||||
bundle: { macOS: { bundleVersion: '123' } },
|
||||
productName: 'KanbanGit',
|
||||
identifier: 'de.kanbangit.desktop',
|
||||
};
|
||||
@@ -31,7 +35,7 @@ function fixture(t) {
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, 'package.json'),
|
||||
JSON.stringify({ version: '1.2.3' }),
|
||||
JSON.stringify({ version: '1.2.3', releaseNotes: 'Release notes' }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/Cargo.toml'),
|
||||
@@ -42,6 +46,21 @@ function fixture(t) {
|
||||
APPLE_SIGNING_IDENTITY:
|
||||
'Developer ID Application: Example (ZZX2KZGTHU)',
|
||||
};
|
||||
const bundle = join(
|
||||
directory,
|
||||
'src-tauri/target/aarch64-apple-darwin/release/bundle',
|
||||
);
|
||||
mkdirSync(join(bundle, 'dmg'), { recursive: true });
|
||||
mkdirSync(join(bundle, 'macos'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(bundle, 'dmg/KanbanGit_1.2.3_aarch64.dmg'),
|
||||
'notarized archive',
|
||||
);
|
||||
writeFileSync(
|
||||
join(bundle, 'macos/KanbanGit.app.tar.gz'),
|
||||
'notarized updater',
|
||||
);
|
||||
writeFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz.sig'), 'signature');
|
||||
const calls = [];
|
||||
const run = (command, args) => {
|
||||
calls.push({ command, args });
|
||||
@@ -54,8 +73,8 @@ function fixture(t) {
|
||||
if (command === 'lipo') {
|
||||
return 'arm64';
|
||||
}
|
||||
if (command === 'ditto' && args.includes('-c')) {
|
||||
writeFileSync(args.at(-1), 'notarized archive');
|
||||
if (command === 'hdiutil' && args[0] === 'attach') {
|
||||
symlinkSync('/Applications', join(args.at(-1), 'Applications'));
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -66,9 +85,11 @@ test('release metadata binds tag, all app versions, identity and architecture',
|
||||
const f = fixture(t);
|
||||
assert.deepEqual(releaseMetadata(f.env, f.directory), {
|
||||
version: '1.2.3',
|
||||
notes: 'Release notes',
|
||||
target: 'aarch64-apple-darwin',
|
||||
arch: 'arm64',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
|
||||
updaterFilename: 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||
});
|
||||
assert.equal(
|
||||
releaseMetadata(
|
||||
@@ -127,8 +148,8 @@ test('packages only a verified app and checks the extracted download again', (t)
|
||||
),
|
||||
`${output.checksum} ${output.filename}\n`,
|
||||
);
|
||||
assert.equal(f.calls.filter((call) => call.command === 'spctl').length, 2);
|
||||
assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 2);
|
||||
assert.equal(f.calls.filter((call) => call.command === 'spctl').length, 3);
|
||||
assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 3);
|
||||
assert.match(
|
||||
f.calls.findLast((call) => call.command === 'spctl').args.at(-1),
|
||||
/extracted\/KanbanGit\.app$/,
|
||||
@@ -136,7 +157,14 @@ test('packages only a verified app and checks the extracted download again', (t)
|
||||
assert.throws(() => packageNotarizedApp(f), /already exists/);
|
||||
});
|
||||
|
||||
for (const failure of ['codesign', 'lipo', 'xcrun', 'spctl', 'ditto']) {
|
||||
for (const failure of [
|
||||
'codesign',
|
||||
'lipo',
|
||||
'xcrun',
|
||||
'spctl',
|
||||
'hdiutil',
|
||||
'tar',
|
||||
]) {
|
||||
test(`does not publish local output when ${failure} verification fails`, (t) => {
|
||||
const f = fixture(t);
|
||||
const run = f.run;
|
||||
|
||||
@@ -40,6 +40,7 @@ function fixture(t, overrides = {}) {
|
||||
arch: 'arm64',
|
||||
log() {},
|
||||
env: {
|
||||
TAURI_SIGNING_PRIVATE_KEY: 'test-private-key',
|
||||
APPLE_SIGNING_IDENTITY: identity,
|
||||
APPLE_NOTARY_KEYCHAIN_PROFILE: 'kanbangit-notary',
|
||||
},
|
||||
@@ -57,7 +58,11 @@ function fixture(t, overrides = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
if (command === 'pnpm' && !overrides.missingApp) {
|
||||
if (
|
||||
command === 'pnpm' &&
|
||||
args.includes('build') &&
|
||||
!overrides.missingApp
|
||||
) {
|
||||
const target = args[args.indexOf('--target') + 1];
|
||||
mkdirSync(
|
||||
join(
|
||||
@@ -137,7 +142,16 @@ function fixture(t, overrides = {}) {
|
||||
stapled = true;
|
||||
}
|
||||
|
||||
if (command === 'ditto') {
|
||||
if (command === 'pnpm' && args.includes('signer')) {
|
||||
writeFileSync(`${args.at(-1)}.sig`, 'signature');
|
||||
}
|
||||
if (command === 'tar') {
|
||||
writeFileSync(args[1], 'updater with stapled ticket');
|
||||
}
|
||||
if (
|
||||
(command === 'hdiutil' && args[0] === 'create') ||
|
||||
command === 'ditto'
|
||||
) {
|
||||
writeFileSync(
|
||||
args.at(-1),
|
||||
stapled
|
||||
@@ -171,11 +185,14 @@ test('publishes only the stapled app archive with its exact SHA-256 checksum aft
|
||||
);
|
||||
assert.equal(
|
||||
readFileSync(`${result.archive}.sha256`, 'utf8'),
|
||||
`${result.checksum} KanbanGit-1.2.3-aarch64-apple-darwin.zip\n`,
|
||||
`${result.checksum} KanbanGit-1.2.3-aarch64-apple-darwin.dmg\n`,
|
||||
);
|
||||
assert.deepEqual(readdirSync(f.destination).sort(), [
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.zip.sha256',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sha256',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sig',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.dmg.sha256',
|
||||
]);
|
||||
assert.deepEqual(readdirSync(f.releaseDirectory), [
|
||||
'1.2.3-aarch64-apple-darwin',
|
||||
@@ -195,6 +212,11 @@ test('publishes only the stapled app archive with its exact SHA-256 checksum aft
|
||||
});
|
||||
|
||||
for (const [name, env, expected] of [
|
||||
[
|
||||
'missing updater signing key',
|
||||
{ TAURI_SIGNING_PRIVATE_KEY: '' },
|
||||
/TAURI_SIGNING_PRIVATE_KEY/,
|
||||
],
|
||||
[
|
||||
'missing identity',
|
||||
{ APPLE_SIGNING_IDENTITY: '' },
|
||||
@@ -343,7 +365,7 @@ test('supports an explicit Intel target with distinct output naming', (t) => {
|
||||
assert.equal(result.target, 'x86_64-apple-darwin');
|
||||
assert.match(
|
||||
result.archive,
|
||||
/1\.2\.3-x86_64-apple-darwin\/KanbanGit-1\.2\.3-x86_64-apple-darwin\.zip$/,
|
||||
/1\.2\.3-x86_64-apple-darwin\/KanbanGit-1\.2\.3-x86_64-apple-darwin\.dmg$/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -454,7 +476,9 @@ test('persists submission ID on timeout and resumes the same app without rebuild
|
||||
);
|
||||
assert.equal(
|
||||
f.calls.some(
|
||||
(call) => call.command === 'pnpm' || call.args[1] === 'submit',
|
||||
(call) =>
|
||||
(call.command === 'pnpm' && call.args.includes('build')) ||
|
||||
call.args[1] === 'submit',
|
||||
),
|
||||
false,
|
||||
);
|
||||
@@ -481,7 +505,9 @@ test('resume never rebuilds a missing previously submitted app', (t) => {
|
||||
);
|
||||
assert.equal(
|
||||
f.calls.some(
|
||||
(call) => call.command === 'pnpm' || call.args[1] === 'submit',
|
||||
(call) =>
|
||||
(call.command === 'pnpm' && call.args.includes('build')) ||
|
||||
call.args[1] === 'submit',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { publishMacos, storageConfiguration } from '../publish-macos.mjs';
|
||||
import { signedFixture } from './updater-fixture.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
|
||||
@@ -13,7 +14,8 @@ function fixture(t) {
|
||||
const metadata = {
|
||||
version: '1.2.3',
|
||||
target: 'aarch64-apple-darwin',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
|
||||
updaterFilename: 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||
};
|
||||
const archive = Buffer.from('verified release');
|
||||
const checksum = createHash('sha256').update(archive).digest('hex');
|
||||
@@ -22,6 +24,17 @@ function fixture(t) {
|
||||
join(directory, `${metadata.filename}.sha256`),
|
||||
`${checksum} ${metadata.filename}\n`,
|
||||
);
|
||||
const updater = Buffer.from('signed update');
|
||||
const signing = signedFixture(updater);
|
||||
writeFileSync(join(directory, metadata.updaterFilename), updater);
|
||||
writeFileSync(
|
||||
join(directory, `${metadata.updaterFilename}.sig`),
|
||||
signing.signature,
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, `${metadata.updaterFilename}.sha256`),
|
||||
`${createHash('sha256').update(updater).digest('hex')} ${metadata.updaterFilename}\n`,
|
||||
);
|
||||
const env = {
|
||||
S3_BUCKET: 'release-bucket',
|
||||
S3_REGION: 'nbg1',
|
||||
@@ -42,18 +55,23 @@ function fixture(t) {
|
||||
return '';
|
||||
}
|
||||
if (operation === 'put-object') {
|
||||
assert.equal(value('--if-none-match'), '*');
|
||||
if (!args.includes('--if-match')) {
|
||||
assert.equal(value('--if-none-match'), '*');
|
||||
}
|
||||
if (objects.has(key)) {
|
||||
throw new Error(
|
||||
'aws failed: (PreconditionFailed) object exists',
|
||||
);
|
||||
if (!args.includes('--if-match')) {
|
||||
throw new Error(
|
||||
'aws failed: (PreconditionFailed) object exists',
|
||||
);
|
||||
}
|
||||
assert.equal(value('--if-match'), '"previous-etag"');
|
||||
}
|
||||
objects.set(key, readFileSync(value('--body')));
|
||||
return '{}';
|
||||
}
|
||||
if (operation === 'get-object') {
|
||||
writeFileSync(args.at(-1), objects.get(key));
|
||||
return '{}';
|
||||
return JSON.stringify({ ETag: '"previous-etag"' });
|
||||
}
|
||||
throw new Error(`Unexpected S3 operation: ${operation}`);
|
||||
};
|
||||
@@ -72,6 +90,9 @@ function fixture(t) {
|
||||
};
|
||||
return {
|
||||
directory,
|
||||
publicKey: signing.publicKey,
|
||||
feedBase:
|
||||
'https://release-bucket.nbg1.your-objectstorage.com/releases/macos',
|
||||
metadata,
|
||||
env,
|
||||
objects,
|
||||
@@ -89,28 +110,32 @@ test('uploads immutable files and writes completion record after public verifica
|
||||
url,
|
||||
`https://release-bucket.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/1.2.3/${f.metadata.filename}`,
|
||||
);
|
||||
assert.equal(
|
||||
f.events.filter((event) => event.operation === 'put-object').length,
|
||||
8,
|
||||
);
|
||||
assert.equal(f.objects.size, 8);
|
||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||
assert.equal(
|
||||
f.events.at(-1).key,
|
||||
'releases/macos/aarch64-apple-darwin/latest.json',
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.events.map((event) => event.operation),
|
||||
[
|
||||
'get-bucket-versioning',
|
||||
'put-object',
|
||||
'public-get',
|
||||
'put-object',
|
||||
'public-get',
|
||||
'put-object',
|
||||
'public-get',
|
||||
],
|
||||
JSON.parse(
|
||||
f.objects.get(
|
||||
'releases/macos/aarch64-apple-darwin/1.2.3/release.json',
|
||||
),
|
||||
),
|
||||
{
|
||||
version: '1.2.3',
|
||||
target: f.metadata.target,
|
||||
url,
|
||||
sha256: createHash('sha256')
|
||||
.update('verified release')
|
||||
.digest('hex'),
|
||||
checksumUrl: `${url}.sha256`,
|
||||
},
|
||||
);
|
||||
assert.equal(f.objects.size, 3);
|
||||
assert(f.events.at(-1).key.endsWith('/release.json'));
|
||||
assert(![...f.objects.keys()].some((key) => key.includes('latest')));
|
||||
assert.deepEqual(JSON.parse(f.objects.get(f.events.at(-1).key)), {
|
||||
version: '1.2.3',
|
||||
target: f.metadata.target,
|
||||
url,
|
||||
sha256: createHash('sha256').update('verified release').digest('hex'),
|
||||
checksumUrl: `${url}.sha256`,
|
||||
});
|
||||
});
|
||||
|
||||
for (const response of ['', ' \n\t', '{}']) {
|
||||
@@ -122,9 +147,9 @@ for (const response of ['', ' \n\t', '{}']) {
|
||||
return args.includes('get-bucket-versioning') ? response : result;
|
||||
};
|
||||
await publishMacos(f);
|
||||
assert.equal(f.objects.size, 3);
|
||||
assert.equal(f.objects.size, 8);
|
||||
assert.equal(f.events.at(-1).operation, 'public-get');
|
||||
assert(f.events.at(-1).key.endsWith('/release.json'));
|
||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,7 +173,7 @@ test('retry accepts byte-identical objects without replacing them', async (t) =>
|
||||
assert.deepEqual([...f.objects.entries()], originals);
|
||||
assert.equal(
|
||||
f.events.filter((event) => event.operation === 'get-object').length,
|
||||
3,
|
||||
8,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -227,10 +252,10 @@ test('a partial upload can resume with the same files', async (t) => {
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /interrupted/);
|
||||
assert.equal(f.objects.size, 1);
|
||||
assert.equal(f.objects.size, 3);
|
||||
f.run = run;
|
||||
await publishMacos(f);
|
||||
assert.equal(f.objects.size, 3);
|
||||
assert.equal(f.objects.size, 8);
|
||||
});
|
||||
|
||||
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
||||
@@ -257,8 +282,8 @@ for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
||||
await publishMacos(f);
|
||||
assert.deepEqual(waits, [2000, 2000]);
|
||||
assert.equal(cancelled, status === 'network' ? 0 : 2);
|
||||
assert.equal(f.objects.size, 3);
|
||||
assert(f.events.at(-1).key.endsWith('/release.json'));
|
||||
assert.equal(f.objects.size, 8);
|
||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -293,15 +318,15 @@ test('checksum access retries finish before publishing the completion record', a
|
||||
const fetchPublic = f.fetchPublic;
|
||||
let failures = 0;
|
||||
f.fetchPublic = async (url, options) => {
|
||||
if (url.endsWith('.sha256') && ++failures <= 2) {
|
||||
assert.equal(f.objects.size, 2);
|
||||
if (url.endsWith(`${f.metadata.filename}.sha256`) && ++failures <= 2) {
|
||||
assert.equal(f.objects.size, 5);
|
||||
return { ok: false, status: 403 };
|
||||
}
|
||||
return fetchPublic(url, options);
|
||||
};
|
||||
await publishMacos(f);
|
||||
assert.equal(failures, 3);
|
||||
assert.equal(f.objects.size, 3);
|
||||
assert.equal(f.objects.size, 8);
|
||||
});
|
||||
|
||||
for (const overrides of [
|
||||
@@ -338,3 +363,67 @@ test('derives Hetzner endpoints and supports an explicit public proxy URL', () =
|
||||
assert.equal(config.publicBase, 'https://downloads.example.com');
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses a destination that differs from the built-in update source', async (t) => {
|
||||
const f = fixture(t);
|
||||
f.feedBase = 'https://another.example/releases/macos';
|
||||
await assert.rejects(publishMacos(f), /does not match/);
|
||||
assert.equal(f.objects.size, 0);
|
||||
});
|
||||
|
||||
test('rejects a corrupt updater archive before publishing any object', async (t) => {
|
||||
const f = fixture(t);
|
||||
writeFileSync(join(f.directory, f.metadata.updaterFilename), 'tampered');
|
||||
await assert.rejects(publishMacos(f), /signature/);
|
||||
assert.equal(f.objects.size, 0);
|
||||
});
|
||||
|
||||
test('atomically promotes a newer feed only after all immutable objects are public', async (t) => {
|
||||
const f = fixture(t);
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
await publishMacos(f);
|
||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||
const immutableVerified = f.events.findIndex(
|
||||
(event) =>
|
||||
event.operation === 'public-get' &&
|
||||
event.key.endsWith('/update.json'),
|
||||
);
|
||||
const latestPut = f.events.findIndex(
|
||||
(event) => event.operation === 'put-object' && event.key === latestKey,
|
||||
);
|
||||
assert(latestPut > immutableVerified);
|
||||
});
|
||||
|
||||
for (const previous of [
|
||||
{ version: '2.0.0' },
|
||||
{ version: '1.2.3', notes: 'different' },
|
||||
]) {
|
||||
test(`does not replace conflicting update feed ${previous.version}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
const original = Buffer.from(JSON.stringify(previous));
|
||||
f.objects.set(key, original);
|
||||
await assert.rejects(publishMacos(f), /downgrade|differs/);
|
||||
assert.deepEqual(f.objects.get(key), original);
|
||||
});
|
||||
}
|
||||
|
||||
test('concurrent feed replacement fails without overwriting the winner', async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('--if-match')) {
|
||||
f.objects.set(
|
||||
key,
|
||||
Buffer.from(JSON.stringify({ version: '2.0.0' })),
|
||||
);
|
||||
throw new Error('(PreconditionFailed) concurrent publication');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /concurrent publication/);
|
||||
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
||||
|
||||
export function signedFixture(bytes) {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const keyId = Buffer.alloc(8, 7);
|
||||
const key = Buffer.concat([
|
||||
Buffer.from('Ed'),
|
||||
keyId,
|
||||
publicKey.export({ type: 'spki', format: 'der' }).subarray(-32),
|
||||
]);
|
||||
const signature = sign(
|
||||
null,
|
||||
createHash('blake2b512').update(bytes).digest(),
|
||||
privateKey,
|
||||
);
|
||||
const trusted = 'timestamp:123456';
|
||||
const globalSignature = sign(
|
||||
null,
|
||||
Buffer.concat([signature, Buffer.from(trusted)]),
|
||||
privateKey,
|
||||
);
|
||||
return {
|
||||
publicKey: Buffer.from(
|
||||
`untrusted comment: test\n${key.toString('base64')}\n`,
|
||||
).toString('base64'),
|
||||
signature: Buffer.from(
|
||||
`untrusted comment: test\n${Buffer.concat([Buffer.from('ED'), keyId, signature]).toString('base64')}\ntrusted comment: ${trusted}\n${globalSignature.toString('base64')}\n`,
|
||||
).toString('base64'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { signedFixture } from './updater-fixture.mjs';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
compareVersions,
|
||||
updateManifest,
|
||||
verifyUpdateSignature,
|
||||
} from '../update-manifest.mjs';
|
||||
import { updatePresentation } from '../ui/updates-state.js';
|
||||
|
||||
test('compares stable versions numerically and rejects prerelease or malformed versions', () => {
|
||||
assert.equal(compareVersions('1.10.0', '1.9.9'), 1);
|
||||
assert.equal(compareVersions('1.0.0', '2.0.0'), -1);
|
||||
assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
|
||||
for (const invalid of [
|
||||
'v1.0.0',
|
||||
'1.0.0-beta',
|
||||
'01.0.0',
|
||||
'1.0.0+2',
|
||||
'',
|
||||
'1.2',
|
||||
]) {
|
||||
assert.throws(() => compareVersions(invalid, '1.0.0'));
|
||||
}
|
||||
});
|
||||
test('verifies minisign archive and trusted comment with the permanent key', () => {
|
||||
const bytes = Buffer.from('signed app');
|
||||
const fixture = signedFixture(bytes);
|
||||
assert.doesNotThrow(() =>
|
||||
verifyUpdateSignature(bytes, fixture.signature, fixture.publicKey),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyUpdateSignature(
|
||||
Buffer.from('tampered app'),
|
||||
fixture.signature,
|
||||
fixture.publicKey,
|
||||
),
|
||||
/signature/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyUpdateSignature(
|
||||
bytes,
|
||||
fixture.signature,
|
||||
signedFixture(bytes).publicKey,
|
||||
),
|
||||
/signature/,
|
||||
);
|
||||
for (const signature of [
|
||||
'',
|
||||
'invalid',
|
||||
Buffer.from(
|
||||
Buffer.from(fixture.signature, 'base64')
|
||||
.toString()
|
||||
.replace('timestamp:123456', 'timestamp:999999'),
|
||||
).toString('base64'),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => verifyUpdateSignature(bytes, signature, fixture.publicKey),
|
||||
/signature/,
|
||||
);
|
||||
}
|
||||
});
|
||||
test('manifest selects the exact supported architecture and embeds release notes', () => {
|
||||
const metadata = {
|
||||
version: '1.2.3',
|
||||
target: 'aarch64-apple-darwin',
|
||||
updaterFilename: 'KanbanGit.app.tar.gz',
|
||||
};
|
||||
assert.deepEqual(
|
||||
updateManifest(
|
||||
metadata,
|
||||
'https://release.example/1.2.3',
|
||||
'signed',
|
||||
'Release notes',
|
||||
),
|
||||
{
|
||||
version: '1.2.3',
|
||||
notes: 'Release notes',
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
url: 'https://release.example/1.2.3/KanbanGit.app.tar.gz',
|
||||
signature: 'signed',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.throws(() =>
|
||||
updateManifest(
|
||||
{ ...metadata, target: 'android' },
|
||||
'https://example.com',
|
||||
'signed',
|
||||
),
|
||||
);
|
||||
});
|
||||
test('update controls prevent concurrent operations and allow failed downloads to retry', () => {
|
||||
for (const phase of [
|
||||
'checking',
|
||||
'downloading',
|
||||
'verifying',
|
||||
'installing',
|
||||
'restarting',
|
||||
]) {
|
||||
const view = updatePresentation({ phase, version: '2.0.0' });
|
||||
assert.equal(view.busy, true);
|
||||
assert.equal(view.canInstall, false);
|
||||
}
|
||||
assert.equal(
|
||||
updatePresentation({ phase: 'available', version: '2.0.0' }).canInstall,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
updatePresentation({ phase: 'error', version: '2.0.0' }).canInstall,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
updatePresentation({ phase: 'current', version: null }).canInstall,
|
||||
false,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user