CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m3s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m41s
CI/CD / test_and_check_app (pull_request) Successful in 6m53s
CI/CD / Publish immutable image (pull_request) Skipped
889 lines
33 KiB
JavaScript
889 lines
33 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { Buffer } from 'node:buffer';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import process from 'node:process';
|
|
import test from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
conditionalEtag,
|
|
publishMacos,
|
|
storageConfiguration,
|
|
} from '../publish-macos.mjs';
|
|
import { signedFixture } from './updater-fixture.mjs';
|
|
|
|
test('publisher rejects the removed resume command before accessing storage', () => {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
fileURLToPath(new URL('../publish-macos.mjs', import.meta.url)),
|
|
'resume',
|
|
],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
assert.equal(result.status, 1);
|
|
assert.equal(result.stdout, '');
|
|
assert.match(
|
|
result.stderr,
|
|
/Use config or upload; upload requires RELEASE_DIRECTORY/,
|
|
);
|
|
});
|
|
|
|
test('conditional writes use bare S3 ETags without weakening the condition', () => {
|
|
const etag = 'b88312b96169f4b2098a4cc43b0abb6a';
|
|
for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) {
|
|
assert.equal(conditionalEtag(value), value.replaceAll('"', ''));
|
|
}
|
|
for (const invalid of [
|
|
undefined,
|
|
null,
|
|
'',
|
|
'*',
|
|
'"*"',
|
|
`W/"${etag}"`,
|
|
`"${etag}`,
|
|
`${etag}"`,
|
|
`${etag},${etag}`,
|
|
`${etag}\r\n`,
|
|
{},
|
|
42,
|
|
]) {
|
|
assert.throws(() => conditionalEtag(invalid), /valid S3 ETag/);
|
|
}
|
|
});
|
|
|
|
function fixture(
|
|
t,
|
|
conflictMessage = 'aws failed: (PreconditionFailed) object exists',
|
|
) {
|
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
|
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
|
const metadata = {
|
|
version: '1.2.3',
|
|
target: 'aarch64-apple-darwin',
|
|
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');
|
|
writeFileSync(join(directory, metadata.filename), archive);
|
|
writeFileSync(
|
|
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',
|
|
AWS_ACCESS_KEY_ID: 'test-key',
|
|
AWS_SECRET_ACCESS_KEY: 'test-secret',
|
|
};
|
|
const objects = new Map();
|
|
const events = [];
|
|
const run = (command, args) => {
|
|
assert.equal(command, 'aws');
|
|
assert.equal(args[1], 'https://nbg1.your-objectstorage.com');
|
|
const operation = args[args.indexOf('s3api') + 1];
|
|
const value = (name) => args[args.indexOf(name) + 1];
|
|
const key = value('--key');
|
|
events.push({ operation, key, args });
|
|
if (operation === 'get-bucket-versioning') {
|
|
assert.equal(value('--output'), 'json');
|
|
return '';
|
|
}
|
|
if (operation === 'put-object') {
|
|
if (!args.includes('--if-match')) {
|
|
assert.equal(value('--if-none-match'), '*');
|
|
}
|
|
if (objects.has(key)) {
|
|
if (!args.includes('--if-match')) {
|
|
throw new Error(conflictMessage);
|
|
}
|
|
assert.equal(
|
|
value('--if-match'),
|
|
'b88312b96169f4b2098a4cc43b0abb6a',
|
|
);
|
|
}
|
|
objects.set(key, readFileSync(value('--body')));
|
|
return '{}';
|
|
}
|
|
if (operation === 'get-object') {
|
|
if (!objects.has(key)) {
|
|
throw new Error('aws failed: (NoSuchKey) object missing');
|
|
}
|
|
writeFileSync(args.at(-1), objects.get(key));
|
|
return JSON.stringify({
|
|
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
|
|
});
|
|
}
|
|
throw new Error(`Unexpected S3 operation: ${operation}`);
|
|
};
|
|
const fetchPublic = async (url, options) => {
|
|
assert.equal(options.redirect, 'error');
|
|
assert.equal(
|
|
new URL(url).hostname,
|
|
'release-bucket.nbg1.your-objectstorage.com',
|
|
);
|
|
const key = new URL(url).pathname.slice(1);
|
|
events.push({ operation: 'public-get', key });
|
|
return {
|
|
ok: objects.has(key),
|
|
arrayBuffer: async () => objects.get(key),
|
|
};
|
|
};
|
|
return {
|
|
directory,
|
|
publicKey: signing.publicKey,
|
|
feedBase:
|
|
'https://release-bucket.nbg1.your-objectstorage.com/releases/macos',
|
|
metadata,
|
|
env,
|
|
objects,
|
|
events,
|
|
run,
|
|
fetchPublic,
|
|
wait: async () => {},
|
|
};
|
|
}
|
|
|
|
test('uploads revalidated files and writes completion record after public verification', async (t) => {
|
|
const f = fixture(t);
|
|
const url = await publishMacos(f);
|
|
assert.equal(
|
|
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,
|
|
9,
|
|
);
|
|
assert.equal(f.objects.size, 9);
|
|
for (const event of f.events.filter(
|
|
(event) => event.operation === 'put-object',
|
|
)) {
|
|
assert.equal(
|
|
event.args[event.args.indexOf('--cache-control') + 1],
|
|
'no-cache, max-age=0, must-revalidate',
|
|
);
|
|
}
|
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
|
assert.equal(
|
|
f.events.at(-1).key,
|
|
'releases/macos/aarch64-apple-darwin/latest.json',
|
|
);
|
|
const downloadKey = 'releases/macos/aarch64-apple-darwin/KanbanGit.dmg';
|
|
assert.deepEqual(
|
|
f.objects.get(downloadKey),
|
|
readFileSync(join(f.directory, f.metadata.filename)),
|
|
);
|
|
const downloadPut = f.events.findIndex(
|
|
(event) =>
|
|
event.operation === 'put-object' && event.key === downloadKey,
|
|
);
|
|
const downloadVerified = f.events.findIndex(
|
|
(event) =>
|
|
event.operation === 'public-get' && event.key === downloadKey,
|
|
);
|
|
const feedPut = f.events.findIndex(
|
|
(event) =>
|
|
event.operation === 'put-object' &&
|
|
event.key.endsWith('/latest.json'),
|
|
);
|
|
assert(downloadPut > 0 && downloadVerified > downloadPut);
|
|
assert(feedPut > downloadVerified);
|
|
assert.deepEqual(
|
|
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`,
|
|
},
|
|
);
|
|
});
|
|
|
|
test('replaces the stable download only for a newer verified release', async (t) => {
|
|
const f = fixture(t);
|
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
|
f.objects.set(downloadKey, Buffer.from('previous download'));
|
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
|
|
|
await publishMacos(f);
|
|
|
|
assert.deepEqual(
|
|
f.objects.get(downloadKey),
|
|
readFileSync(join(f.directory, f.metadata.filename)),
|
|
);
|
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
|
const replacement = f.events.find(
|
|
(event) =>
|
|
event.operation === 'put-object' &&
|
|
event.key === downloadKey &&
|
|
event.args.includes('--if-match'),
|
|
);
|
|
assert(replacement);
|
|
});
|
|
|
|
test('a newer published version protects the stable download even when its alias is missing', async (t) => {
|
|
const f = fixture(t);
|
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '2.0.0' })));
|
|
|
|
await assert.rejects(publishMacos(f), /downgrade/);
|
|
|
|
assert.equal(f.objects.has(downloadKey), false);
|
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '2.0.0');
|
|
});
|
|
|
|
test('a published version cannot replace a different stable download', async (t) => {
|
|
const f = fixture(t);
|
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
|
const original = Buffer.from('published download');
|
|
f.objects.set(downloadKey, original);
|
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.2.3' })));
|
|
|
|
await assert.rejects(publishMacos(f), /bump the app version/);
|
|
|
|
assert.deepEqual(f.objects.get(downloadKey), original);
|
|
});
|
|
|
|
test('a failed public stable download blocks feed promotion', async (t) => {
|
|
const f = fixture(t);
|
|
const fetchPublic = f.fetchPublic;
|
|
f.fetchPublic = (url, options) =>
|
|
url.endsWith('/KanbanGit.dmg')
|
|
? Promise.resolve({ ok: false, status: 403 })
|
|
: fetchPublic(url, options);
|
|
|
|
await assert.rejects(publishMacos(f), /KanbanGit\.dmg: HTTP 403/);
|
|
|
|
assert.equal(
|
|
f.objects.has(`releases/macos/${f.metadata.target}/latest.json`),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('concurrent stable download replacement preserves the winner', async (t) => {
|
|
const f = fixture(t);
|
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
|
f.objects.set(downloadKey, Buffer.from('previous download'));
|
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
|
const run = f.run;
|
|
f.run = (command, args) => {
|
|
if (args.includes('--if-match') && args.includes(downloadKey)) {
|
|
f.objects.set(downloadKey, Buffer.from('concurrent winner'));
|
|
throw new Error('(PreconditionFailed) concurrent replacement');
|
|
}
|
|
return run(command, args);
|
|
};
|
|
|
|
await assert.rejects(publishMacos(f), /concurrent replacement/);
|
|
|
|
assert.equal(f.objects.get(downloadKey).toString(), 'concurrent winner');
|
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.0.0');
|
|
});
|
|
|
|
for (const response of ['', ' \n\t', '{}']) {
|
|
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
|
|
const f = fixture(t);
|
|
const run = f.run;
|
|
f.run = (command, args) => {
|
|
const result = run(command, args);
|
|
return args.includes('get-bucket-versioning') ? response : result;
|
|
};
|
|
await publishMacos(f);
|
|
assert.equal(f.objects.size, 9);
|
|
assert.equal(f.events.at(-1).operation, 'public-get');
|
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
|
});
|
|
}
|
|
|
|
for (const response of ['{', 'null', '[]', '"Enabled"', 'false']) {
|
|
test(`invalid versioning response ${response} aborts before any upload`, async (t) => {
|
|
const f = fixture(t);
|
|
f.run = (_command, args) => {
|
|
assert(args.includes('get-bucket-versioning'));
|
|
return response;
|
|
};
|
|
await assert.rejects(publishMacos(f), /Invalid .*versioning/);
|
|
assert.equal(f.objects.size, 0);
|
|
});
|
|
}
|
|
|
|
test('retry accepts byte-identical objects without replacing them', async (t) => {
|
|
const f = fixture(t);
|
|
await publishMacos(f);
|
|
const originals = [...f.objects.entries()];
|
|
await publishMacos(f);
|
|
assert.deepEqual([...f.objects.entries()], originals);
|
|
assert.equal(
|
|
f.events.filter((event) => event.operation === 'get-object').length,
|
|
11,
|
|
);
|
|
});
|
|
|
|
for (const published of ['1.2.3', '1.2.4']) {
|
|
test(`published release stays protected when the feed is at ${published}`, async (t) => {
|
|
const f = fixture(t);
|
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
|
f.objects.set(key, Buffer.from('previous version content'));
|
|
f.objects.set(
|
|
`releases/macos/${f.metadata.target}/latest.json`,
|
|
Buffer.from(JSON.stringify({ version: published })),
|
|
);
|
|
await assert.rejects(publishMacos(f), /bump the app version/);
|
|
assert.equal(f.objects.get(key).toString(), 'previous version content');
|
|
assert.equal(f.objects.size, 2);
|
|
});
|
|
}
|
|
|
|
test('checksum mismatch aborts before contacting S3', async (t) => {
|
|
const f = fixture(t);
|
|
writeFileSync(join(f.directory, f.metadata.filename), 'tampered');
|
|
await assert.rejects(publishMacos(f), /SHA-256/);
|
|
assert.equal(f.events.length, 0);
|
|
});
|
|
|
|
for (const status of ['Enabled', 'Suspended']) {
|
|
test(`rejects ${status} bucket versioning before any write`, async (t) => {
|
|
const f = fixture(t);
|
|
f.run = () => JSON.stringify({ Status: status });
|
|
await assert.rejects(publishMacos(f), /unversioned/);
|
|
assert.equal(f.objects.size, 0);
|
|
});
|
|
}
|
|
|
|
for (const operation of ['get-bucket-versioning', 'put-object', 'get-object']) {
|
|
test(`${operation} access or network failure cannot be mistaken for a missing object`, async (t) => {
|
|
const f = fixture(t);
|
|
await publishMacos(f);
|
|
const run = f.run;
|
|
f.events.length = 0;
|
|
f.run = (command, args) => {
|
|
if (args.includes(operation)) {
|
|
throw new Error('AccessDenied');
|
|
}
|
|
return run(command, args);
|
|
};
|
|
await assert.rejects(publishMacos(f), /AccessDenied/);
|
|
assert(!f.events.some((event) => event.key.endsWith('/release.json')));
|
|
});
|
|
}
|
|
|
|
for (const failure of ['private', 'wrong-content', 'network']) {
|
|
test(`public download ${failure} prevents completion record`, async (t) => {
|
|
const f = fixture(t);
|
|
f.fetchPublic = async () => {
|
|
if (failure === 'network') {
|
|
throw new Error('network failure');
|
|
}
|
|
return {
|
|
ok: failure !== 'private',
|
|
status: failure === 'private' ? 403 : 200,
|
|
arrayBuffer: async () => Buffer.from('wrong'),
|
|
};
|
|
};
|
|
await assert.rejects(publishMacos(f));
|
|
assert(
|
|
![...f.objects.keys()].some((key) => key.endsWith('/release.json')),
|
|
);
|
|
});
|
|
}
|
|
|
|
test('a partial upload can be retried with the same files', async (t) => {
|
|
const f = fixture(t);
|
|
const run = f.run;
|
|
f.run = (command, args) => {
|
|
if (
|
|
args.includes('put-object') &&
|
|
args[args.indexOf('--key') + 1].endsWith('.sha256')
|
|
) {
|
|
throw new Error('upload interrupted');
|
|
}
|
|
return run(command, args);
|
|
};
|
|
await assert.rejects(publishMacos(f), /interrupted/);
|
|
assert.equal(f.objects.size, 3);
|
|
f.run = run;
|
|
await publishMacos(f);
|
|
assert.equal(f.objects.size, 9);
|
|
});
|
|
|
|
function rebuild(f) {
|
|
const updater = Buffer.from('rebuilt signed update');
|
|
const signing = signedFixture(updater);
|
|
f.publicKey = signing.publicKey;
|
|
f.metadata.notes = 'Fixed build of the same version';
|
|
for (const [name, contents] of [
|
|
[f.metadata.filename, Buffer.from('rebuilt disk image')],
|
|
[f.metadata.updaterFilename, updater],
|
|
]) {
|
|
writeFileSync(join(f.directory, name), contents);
|
|
writeFileSync(
|
|
join(f.directory, `${name}.sha256`),
|
|
`${createHash('sha256').update(contents).digest('hex')} ${name}\n`,
|
|
);
|
|
}
|
|
writeFileSync(
|
|
join(f.directory, `${f.metadata.updaterFilename}.sig`),
|
|
signing.signature,
|
|
);
|
|
}
|
|
|
|
for (const previous of [null, '1.0.0']) {
|
|
for (const interruptedFile of ['.sha256', '/latest.json']) {
|
|
test(`rebuild replaces unfinished ${interruptedFile} release with previous feed ${previous}`, async (t) => {
|
|
const f = fixture(t);
|
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
|
const previousBytes =
|
|
previous === null
|
|
? undefined
|
|
: Buffer.from(JSON.stringify({ version: previous }));
|
|
if (previousBytes) {
|
|
f.objects.set(latestKey, previousBytes);
|
|
}
|
|
const run = f.run;
|
|
f.run = (command, args) => {
|
|
if (
|
|
args.includes('put-object') &&
|
|
args[args.indexOf('--key') + 1].endsWith(interruptedFile)
|
|
) {
|
|
throw new Error('upload interrupted');
|
|
}
|
|
return run(command, args);
|
|
};
|
|
await assert.rejects(publishMacos(f), /upload interrupted/);
|
|
assert.deepEqual(f.objects.get(latestKey), previousBytes);
|
|
rebuild(f);
|
|
f.events.length = 0;
|
|
let promoted = false;
|
|
f.run = (command, args) => {
|
|
if (!promoted) {
|
|
assert.deepEqual(f.objects.get(latestKey), previousBytes);
|
|
}
|
|
const result = run(command, args);
|
|
if (args.includes('put-object') && args.includes(latestKey)) {
|
|
promoted = true;
|
|
}
|
|
return result;
|
|
};
|
|
await publishMacos(f);
|
|
assert.equal(f.objects.size, 9);
|
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
|
assert.equal(
|
|
JSON.parse(f.objects.get(latestKey)).notes,
|
|
f.metadata.notes,
|
|
);
|
|
for (const name of [
|
|
f.metadata.filename,
|
|
`${f.metadata.filename}.sha256`,
|
|
f.metadata.updaterFilename,
|
|
`${f.metadata.updaterFilename}.sig`,
|
|
`${f.metadata.updaterFilename}.sha256`,
|
|
]) {
|
|
assert.deepEqual(
|
|
f.objects.get(
|
|
`releases/macos/${f.metadata.target}/1.2.3/${name}`,
|
|
),
|
|
readFileSync(join(f.directory, name)),
|
|
);
|
|
}
|
|
const published = [...f.objects.entries()];
|
|
rebuild(f);
|
|
await assert.rejects(publishMacos(f), /already been released/);
|
|
assert.deepEqual([...f.objects.entries()], published);
|
|
});
|
|
}
|
|
}
|
|
|
|
test('failure after promoting the feed still protects that version from rebuilding', async (t) => {
|
|
const f = fixture(t);
|
|
const fetchPublic = f.fetchPublic;
|
|
f.fetchPublic = async (url, options) =>
|
|
url.endsWith('/latest.json')
|
|
? { ok: false, status: 403 }
|
|
: fetchPublic(url, options);
|
|
await assert.rejects(publishMacos(f), /HTTP 403/);
|
|
const published = [...f.objects.entries()];
|
|
rebuild(f);
|
|
await assert.rejects(publishMacos(f), /already been released/);
|
|
assert.deepEqual([...f.objects.entries()], published);
|
|
});
|
|
|
|
test('a concurrent change to a partial artifact is not overwritten', async (t) => {
|
|
const f = fixture(t);
|
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
|
f.objects.set(key, Buffer.from('unfinished old build'));
|
|
const run = f.run;
|
|
f.run = (command, args) => {
|
|
if (args.includes('--if-match')) {
|
|
assert(args.includes(key));
|
|
f.objects.set(key, Buffer.from('concurrent winner'));
|
|
throw new Error('(PreconditionFailed) concurrent replacement');
|
|
}
|
|
return run(command, args);
|
|
};
|
|
await assert.rejects(publishMacos(f), /concurrent replacement/);
|
|
assert.equal(f.objects.get(key).toString(), 'concurrent winner');
|
|
assert.equal(f.objects.size, 1);
|
|
});
|
|
|
|
for (const failure of [
|
|
'AccessDenied',
|
|
'network failure',
|
|
'invalid JSON',
|
|
'invalid version',
|
|
]) {
|
|
test(`cannot replace a partial artifact when the feed check has ${failure}`, async (t) => {
|
|
const f = fixture(t);
|
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
|
f.objects.set(key, Buffer.from('unfinished old build'));
|
|
f.objects.set(
|
|
latestKey,
|
|
Buffer.from(
|
|
failure === 'invalid version' ? '{"version":"invalid"}' : '{',
|
|
),
|
|
);
|
|
const originals = [...f.objects.entries()];
|
|
const run = f.run;
|
|
f.run = (command, args) => {
|
|
if (
|
|
args.includes('get-object') &&
|
|
args.includes(latestKey) &&
|
|
!failure.startsWith('invalid')
|
|
) {
|
|
throw new Error(failure);
|
|
}
|
|
return run(command, args);
|
|
};
|
|
await assert.rejects(publishMacos(f));
|
|
assert.deepEqual([...f.objects.entries()], originals);
|
|
});
|
|
}
|
|
|
|
for (const listing of [
|
|
{ KeyCount: 0, IsTruncated: false },
|
|
{ KeyCount: 1, IsTruncated: false, Contents: [{ Key: 'latest.json' }] },
|
|
{},
|
|
{ KeyCount: 0, IsTruncated: true },
|
|
{ KeyCount: 0, IsTruncated: false, Contents: '' },
|
|
'AccessDenied',
|
|
]) {
|
|
test(`ambiguous feed read error needs a proven empty listing: ${JSON.stringify(listing)}`, async (t) => {
|
|
const f = fixture(t);
|
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
|
f.objects.set(key, Buffer.from('unfinished old build'));
|
|
const run = f.run;
|
|
f.run = (command, args) => {
|
|
if (args.includes('get-object') && args.includes(latestKey)) {
|
|
throw new Error(
|
|
"argument of type 'NoneType' is not a container or iterable",
|
|
);
|
|
}
|
|
if (args.includes('list-objects-v2')) {
|
|
assert.equal(args[args.indexOf('--prefix') + 1], latestKey);
|
|
if (listing === 'AccessDenied') {
|
|
throw new Error('AccessDenied');
|
|
}
|
|
return JSON.stringify(listing);
|
|
}
|
|
return run(command, args);
|
|
};
|
|
if (
|
|
listing.KeyCount === 0 &&
|
|
listing.IsTruncated === false &&
|
|
!('Contents' in listing)
|
|
) {
|
|
await publishMacos(f);
|
|
assert.equal(f.objects.size, 9);
|
|
} else {
|
|
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
|
|
assert.equal(f.objects.get(key).toString(), 'unfinished old build');
|
|
assert.equal(f.objects.size, 1);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
|
test(`temporary public download failure ${status} recovers before completion`, async (t) => {
|
|
const f = fixture(t);
|
|
const fetchPublic = f.fetchPublic;
|
|
let attempts = 0;
|
|
let cancelled = 0;
|
|
const waits = [];
|
|
f.wait = async (ms) => waits.push(ms);
|
|
f.fetchPublic = async (...args) => {
|
|
if (++attempts <= 2) {
|
|
if (status === 'network') {
|
|
throw new Error('temporary network failure');
|
|
}
|
|
return {
|
|
ok: false,
|
|
status,
|
|
body: { cancel: async () => cancelled++ },
|
|
};
|
|
}
|
|
return fetchPublic(...args);
|
|
};
|
|
await publishMacos(f);
|
|
assert.deepEqual(waits, [2000, 2000]);
|
|
assert.equal(cancelled, status === 'network' ? 0 : 2);
|
|
assert.equal(f.objects.size, 9);
|
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
|
});
|
|
}
|
|
|
|
test('persistent public access denial stops after five attempts with HTTP status', async (t) => {
|
|
const f = fixture(t);
|
|
let attempts = 0;
|
|
const waits = [];
|
|
f.wait = async (ms) => waits.push(ms);
|
|
f.fetchPublic = async () => {
|
|
attempts++;
|
|
return { ok: false, status: 403 };
|
|
};
|
|
await assert.rejects(publishMacos(f), /HTTP 403/);
|
|
assert.equal(attempts, 5);
|
|
assert.equal(waits.length, 4);
|
|
assert.equal(f.objects.size, 1);
|
|
});
|
|
|
|
test('incorrect public bytes fail immediately without retry or completion record', async (t) => {
|
|
const f = fixture(t);
|
|
f.wait = async () => assert.fail('Do not retry mismatched content');
|
|
f.fetchPublic = async () => ({
|
|
ok: true,
|
|
arrayBuffer: async () => Buffer.from('incorrect'),
|
|
});
|
|
await assert.rejects(publishMacos(f), /SHA-256 mismatch/);
|
|
assert.equal(f.objects.size, 1);
|
|
});
|
|
|
|
test('checksum access retries finish before publishing the completion record', async (t) => {
|
|
const f = fixture(t);
|
|
const fetchPublic = f.fetchPublic;
|
|
let failures = 0;
|
|
f.fetchPublic = async (url, options) => {
|
|
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, 9);
|
|
});
|
|
|
|
for (const overrides of [
|
|
{ S3_REGION: '' },
|
|
{ S3_REGION: 'eu-central-1' },
|
|
{ S3_BUCKET: '' },
|
|
{ S3_BUCKET: 'other/../bucket' },
|
|
{ S3_PREFIX: '../other' },
|
|
{ S3_PUBLIC_BASE_URL: 'http://downloads.example.com' },
|
|
{ S3_PUBLIC_BASE_URL: 'https://secret@downloads.example.com' },
|
|
{ S3_PUBLIC_BASE_URL: 'https://downloads.example.com?token=secret' },
|
|
{ AWS_ACCESS_KEY_ID: '' },
|
|
{ AWS_SECRET_ACCESS_KEY: '' },
|
|
]) {
|
|
test(`invalid storage configuration is rejected before S3 access: ${JSON.stringify(overrides)}`, async (t) => {
|
|
const f = fixture(t);
|
|
f.env = { ...f.env, ...overrides };
|
|
await assert.rejects(publishMacos(f));
|
|
assert.equal(f.events.length, 0);
|
|
});
|
|
}
|
|
|
|
test('derives Hetzner endpoints and supports an explicit public proxy URL', () => {
|
|
for (const region of ['nbg1', 'fsn1', 'hel1']) {
|
|
const config = storageConfiguration({
|
|
S3_REGION: region,
|
|
S3_BUCKET: 'release-bucket',
|
|
S3_PUBLIC_BASE_URL: 'https://downloads.example.com/',
|
|
});
|
|
assert.equal(
|
|
config.endpoint,
|
|
`https://${region}.your-objectstorage.com`,
|
|
);
|
|
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 release files 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 releaseVerified = 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 > releaseVerified);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
for (const message of [
|
|
"aws failed (exit 255). aws: [ERROR]: argument of type 'NoneType' is not a container or iterable",
|
|
"aws failed (exit 255). argument of type 'NoneType' is not iterable",
|
|
]) {
|
|
test(`empty S3 error message permits verified promotion and identical retry: ${message}`, async (t) => {
|
|
const f = fixture(t, message);
|
|
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
|
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
|
await publishMacos(f);
|
|
assert.equal(JSON.parse(f.objects.get(key)).version, '1.2.3');
|
|
const published = [...f.objects.entries()];
|
|
await publishMacos(f);
|
|
assert.deepEqual([...f.objects.entries()], published);
|
|
});
|
|
|
|
test(`empty S3 error cannot replace a published release: ${message}`, async (t) => {
|
|
const f = fixture(t, message);
|
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
|
f.objects.set(key, Buffer.from('different existing release'));
|
|
f.objects.set(
|
|
`releases/macos/${f.metadata.target}/latest.json`,
|
|
Buffer.from(JSON.stringify({ version: '1.2.3' })),
|
|
);
|
|
await assert.rejects(publishMacos(f), /bump the app version/);
|
|
assert.equal(f.objects.size, 2);
|
|
assert.equal(
|
|
f.objects.get(key).toString(),
|
|
'different existing release',
|
|
);
|
|
});
|
|
|
|
for (const previous of [
|
|
{ version: '2.0.0' },
|
|
{ version: '1.2.3', notes: 'different' },
|
|
]) {
|
|
test(`empty S3 error cannot bypass feed conflict checks ${previous.version}: ${message}`, async (t) => {
|
|
const f = fixture(t, message);
|
|
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(`empty S3 error aborts when existing contents cannot be read: ${message}`, async (t) => {
|
|
const f = fixture(t, message);
|
|
const run = f.run;
|
|
f.run = (command, args) => {
|
|
if (args.includes('put-object')) {
|
|
throw new Error(message);
|
|
}
|
|
if (args.includes('get-object')) {
|
|
throw new Error('AccessDenied');
|
|
}
|
|
return run(command, args);
|
|
};
|
|
await assert.rejects(publishMacos(f), /S3 get-object .*AccessDenied/);
|
|
assert.equal(f.objects.size, 0);
|
|
});
|
|
|
|
test(`empty S3 error during conditional replacement preserves a concurrent winner: ${message}`, async (t) => {
|
|
const f = fixture(t, message);
|
|
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;
|
|
let attempts = 0;
|
|
f.run = (command, args) => {
|
|
if (args.includes('--if-match')) {
|
|
attempts++;
|
|
f.objects.set(
|
|
key,
|
|
Buffer.from(JSON.stringify({ version: '2.0.0' })),
|
|
);
|
|
throw new Error(message);
|
|
}
|
|
return run(command, args);
|
|
};
|
|
await assert.rejects(
|
|
publishMacos(f),
|
|
/S3 put-object .*latest.json: .*NoneType/,
|
|
);
|
|
assert.equal(attempts, 1);
|
|
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
|
|
});
|
|
}
|