fix(KAN-65): normalize Hetzner conditional write ETags
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m34s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5m16s

This commit is contained in:
2026-09-28 07:24:12 +02:00
parent dc9f400f34
commit 40b66bf7ce
3 changed files with 251 additions and 13 deletions
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { randomUUID } 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 { conditionalEtag } from '../publish-macos.mjs';
test(
'Hetzner conditional writes accept a bare ETag and reject a stale ETag',
{
skip: process.env.KANBANGIT_S3_INTEGRATION !== '1',
},
() => {
const bucket = process.env.AWS_BUCKET;
assert(bucket, 'Set AWS_BUCKET to a test bucket');
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-s3-etag-'));
const key = `kanbangit-integration-tests/${randomUUID()}.txt`;
const file = join(directory, 'object.txt');
const downloaded = join(directory, 'downloaded.txt');
const run = (operation, args) => {
const result = spawnSync(
'aws',
[
'--debug',
'--endpoint-url',
process.env.AWS_ENDPOINT,
'--region',
process.env.AWS_DEFAULT_REGION,
'--no-cli-pager',
's3api',
operation,
'--bucket',
bucket,
'--key',
key,
...args,
],
{ encoding: 'utf8', timeout: 30000 },
);
// Never expose debug headers: they contain the signed Authorization header.
return {
status: result.status,
stdout: result.stdout,
codes: [
...(result.stderr || '').matchAll(/<Code>([^<]*)<\/Code>/g),
].map((match) => match[1]),
};
};
let created = false;
try {
writeFileSync(file, 'first');
const initial = run('put-object', [
'--body',
file,
'--if-none-match',
'*',
]);
assert.equal(initial.status, 0, `Create failed: ${initial.codes}`);
created = true;
const read = run('get-object', ['--output', 'json', downloaded]);
assert.equal(read.status, 0, `Read failed: ${read.codes}`);
const etag = JSON.parse(read.stdout).ETag;
const quoted = run('put-object', [
'--body',
file,
'--if-match',
etag,
]);
console.log(
`Quoted ETag result: exit ${quoted.status}, S3 ${quoted.codes.join(', ')}`,
);
const bare = conditionalEtag(etag);
writeFileSync(file, 'second');
const updated = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.equal(
updated.status,
0,
`Bare ETag replacement failed: ${updated.codes}`,
);
writeFileSync(file, 'stale writer');
const stale = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.notEqual(stale.status, 0);
assert(
stale.codes.includes('PreconditionFailed'),
`Unexpected stale error: ${stale.codes}`,
);
assert.equal(run('get-object', [downloaded]).status, 0);
assert.equal(readFileSync(downloaded, 'utf8'), 'second');
} finally {
try {
if (created) {
const cleanup = run('delete-object', []);
assert.equal(
cleanup.status,
0,
`Cleanup failed: ${cleanup.codes}`,
);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
},
);
+67 -3
View File
@@ -5,9 +5,68 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { publishMacos, storageConfiguration } from '../publish-macos.mjs';
import {
conditionalEtag,
publishMacos,
resumeMacos,
storageConfiguration,
} from '../publish-macos.mjs';
import { signedFixture } from './updater-fixture.mjs';
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/);
}
});
test('resume verifies already published artifacts and promotes the feed without rebuilding', async (t) => {
const f = fixture(t);
await publishMacos(f);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
const originals = new Map(f.objects);
await resumeMacos(f);
assert.equal(JSON.parse(f.objects.get(key)).version, f.metadata.version);
for (const [name, contents] of originals) {
if (name !== key) {
assert.deepEqual(f.objects.get(name), contents);
}
}
});
for (const failure of ['missing', 'tampered']) {
test(`resume rejects ${failure} artifacts before any S3 writes`, async (t) => {
const f = fixture(t);
await publishMacos(f);
const key = `releases/macos/${f.metadata.target}/${f.metadata.version}/${f.metadata.updaterFilename}`;
if (failure === 'missing') {
f.objects.delete(key);
} else {
f.objects.set(key, Buffer.from('tampered'));
}
f.events.length = 0;
await assert.rejects(resumeMacos(f), /incomplete release|signature/);
assert(!f.events.some((event) => event.operation === 'put-object'));
});
}
function fixture(
t,
conflictMessage = 'aws failed: (PreconditionFailed) object exists',
@@ -65,14 +124,19 @@ function fixture(
if (!args.includes('--if-match')) {
throw new Error(conflictMessage);
}
assert.equal(value('--if-match'), '"previous-etag"');
assert.equal(
value('--if-match'),
'b88312b96169f4b2098a4cc43b0abb6a',
);
}
objects.set(key, readFileSync(value('--body')));
return '{}';
}
if (operation === 'get-object') {
writeFileSync(args.at(-1), objects.get(key));
return JSON.stringify({ ETag: '"previous-etag"' });
return JSON.stringify({
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
});
}
throw new Error(`Unexpected S3 operation: ${operation}`);
};