Files
dsh-web/image/reconcile-profile.test.mjs

281 lines
13 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs, { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync, readdirSync, statSync, linkSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { tmpdir } from 'node:os';
import { join, basename } from 'node:path';
import { BUILTIN_DEPENDENCIES, BUILTIN_OVERRIDES, reconcileManifest, reconcileProfile } from './reconcile-profile.mjs';
import { initProfile } from './init-profile.mjs';
const old = name => 'file:/usr/local/lib/dsh-mgr/' + name + '-0.0.1-rc.1+build.2.tgz';
test('runtime-7.9 plugin references migrate to runtime-7.11 without changing bundle choices', t => {
const bundles = ['dsh-web-mobile', 'dsh-chat-manager'];
const f = fixture(t, {
dependencies: {
'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.1.0.tgz',
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.2.tgz',
'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.16-dsh-mgr.1.tgz',
},
dsh: { profile: { bundles } },
});
const result = f.run();
assert.equal(result.changed, true);
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
const current = JSON.parse(readFileSync(f.path));
for (const name of Object.keys(current.dependencies)) assert.equal(current.dependencies[name], BUILTIN_DEPENDENCIES[name]);
assert.deepEqual(current.dsh.profile.bundles, bundles);
assert.deepEqual(f.run(), { changed: false });
});
test('runtime-7.10 plugin references migrate to runtime-7.11 without changing bundle choices', t => {
const bundles = ['dsh-web-mobile', 'dsh-chat-manager'];
const f = fixture(t, {
dependencies: {
'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.2.1.tgz',
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.15.tgz',
'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.18-dsh-mgr.1.tgz',
},
dsh: { profile: { bundles } },
});
const result = f.run();
assert.equal(result.changed, true);
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
const current = JSON.parse(readFileSync(f.path));
for (const name of Object.keys(current.dependencies)) assert.equal(current.dependencies[name], BUILTIN_DEPENDENCIES[name]);
assert.deepEqual(current.dsh.profile.bundles, bundles);
assert.deepEqual(f.run(), { changed: false });
});
function fixture(t, manifest) {
const root = mkdtempSync(join(tmpdir(), 'dsh-reconcile-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const profile = join(root, 'profiles/web');
const archiveDirectory = join(root, 'archives');
mkdirSync(profile, { recursive: true });
mkdirSync(archiveDirectory);
for (const reference of [...Object.values(BUILTIN_DEPENDENCIES), ...Object.values(BUILTIN_OVERRIDES)]) {
writeFileSync(join(archiveDirectory, basename(reference)), 'fixture');
}
const path = join(profile, 'package.json');
const original = typeof manifest === 'string' ? manifest : JSON.stringify(manifest, null, 4) + '\n';
writeFileSync(path, original);
return { root, profile, path, original, archiveDirectory, run: () => reconcileProfile(profile, { archiveDirectory }) };
}
test('migrates all sections, backs up exact bytes, replaces atomically with 0600, and is idempotent', t => {
const f = fixture(t, {
dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') },
devDependencies: { 'dsh-chat-manager': old('dsh-chat-manager') },
optionalDependencies: { 'dsh-free-search': old('dsh-free-search') },
dsh: { profile: { bundles: [] } },
});
const originalLink = join(f.root, 'original-link');
linkSync(f.path, originalLink);
const first = f.run();
assert.equal(first.changed, true);
assert.equal(readFileSync(first.backupPath, 'utf8'), f.original);
assert.equal(readFileSync(originalLink, 'utf8'), f.original);
assert.equal(statSync(f.path).mode & 0o777, 0o600);
assert.equal(statSync(first.backupPath).mode & 0o777, 0o600);
const current = JSON.parse(readFileSync(f.path));
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
for (const name of Object.keys(current[section])) assert.equal(current[section][name], BUILTIN_DEPENDENCIES[name]);
}
assert.deepEqual(current.pnpm.overrides, BUILTIN_OVERRIDES);
assert.deepEqual(current.dsh.profile.bundles, []);
const before = statSync(f.path);
assert.deepEqual(f.run(), { changed: false });
assert.equal(statSync(f.path).ino, before.ino);
assert.equal(statSync(f.path).mtimeMs, before.mtimeMs);
assert.equal(readdirSync(f.profile).length, 3);
assert.equal(readFileSync(join(f.profile, '.dsh-lockfile-pending'), 'utf8'), 'builtin profile migration\n');
writeFileSync(f.path, f.original);
assert.notEqual(f.run().backupPath, first.backupPath);
assert.equal(readFileSync(first.backupPath, 'utf8'), f.original);
});
function withSyncTrace(f, callback, failAt) {
const originals = Object.fromEntries(
['openSync', 'writeFileSync', 'fsyncSync', 'closeSync', 'renameSync'].map(name => [name, fs[name]]),
);
const descriptors = new Map();
const events = [];
let directoryOpens = 0;
fs.openSync = (path, ...args) => {
const fd = originals.openSync(path, ...args);
const target = path === f.profile ? `directory-${++directoryOpens}`
: String(path).startsWith(`${f.path}.backup-`) ? 'backup'
: String(path).startsWith(`${f.path}.tmp-`) ? 'temporary' : undefined;
if (target) {
descriptors.set(fd, target);
events.push(`open:${target}`);
}
return fd;
};
for (const [name, operation] of [['writeFileSync', 'write'], ['fsyncSync', 'fsync'], ['closeSync', 'close']]) {
fs[name] = (fd, ...args) => {
const target = descriptors.get(fd);
if (target) {
const event = `${operation}:${target}`;
events.push(event);
if (event === failAt) throw new Error(`injected ${event} failure`);
}
const result = originals[name](fd, ...args);
if (name === 'closeSync') descriptors.delete(fd);
return result;
};
}
fs.renameSync = (source, destination) => {
assert.ok(source.startsWith(`${f.path}.tmp-`));
assert.equal(destination, f.path);
events.push('rename:manifest');
return originals.renameSync(source, destination);
};
syncBuiltinESMExports();
try {
callback(events);
} finally {
Object.assign(fs, originals);
syncBuiltinESMExports();
}
}
const durableWriteOrder = [
'open:backup', 'write:backup', 'fsync:backup', 'close:backup',
'open:directory-1', 'fsync:directory-1', 'close:directory-1',
'open:temporary', 'write:temporary', 'fsync:temporary', 'close:temporary',
'open:directory-2', 'fsync:directory-2', 'close:directory-2',
'rename:manifest',
'open:directory-3', 'fsync:directory-3', 'close:directory-3',
];
test('syncs backup and directory before replacement, then syncs the renamed manifest directory', t => {
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
withSyncTrace(f, events => {
const result = f.run();
assert.equal(result.changed, true);
assert.deepEqual(events, durableWriteOrder);
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
assert.equal(JSON.parse(readFileSync(f.path)).dependencies['dsh-web-mobile'], BUILTIN_DEPENDENCIES['dsh-web-mobile']);
});
});
for (const target of ['backup', 'directory-1', 'temporary']) {
test(`${target} fsync failure preserves the original manifest and does not rename`, t => {
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
const before = statSync(f.path);
const failAt = `fsync:${target}`;
withSyncTrace(f, events => {
assert.throws(f.run, { message: `injected ${failAt} failure` });
assert.deepEqual(events, [
...durableWriteOrder.slice(0, durableWriteOrder.indexOf(failAt) + 1),
`close:${target}`,
]);
}, failAt);
assert.equal(readFileSync(f.path, 'utf8'), f.original);
assert.equal(statSync(f.path).ino, before.ino);
assert.equal(statSync(f.path).mtimeMs, before.mtimeMs);
const backups = readdirSync(f.profile).filter(name => name.startsWith('package.json.backup-'));
assert.equal(backups.length, 1);
assert.equal(readFileSync(join(f.profile, backups[0]), 'utf8'), f.original);
assert.deepEqual(readdirSync(f.profile).sort(), ['package.json', ...backups].sort());
});
}
test('preserves deleted plugins, disabled bundles, custom dependencies and overrides', t => {
const custom = {
dependencies: { 'dsh-chat-manager': old('dsh-chat-manager'), 'dsh-web-mobile': '^3.0.0', other: old('other') },
devDependencies: { 'dsh-free-search': 'file:/custom/dsh-free-search-0.1.0.tgz' },
optionalDependencies: { 'dsh-preview-download': 'file:/usr/local/lib/dsh-mgr/not-the-same-1.0.0.tgz' },
pnpm: { overrides: { 'dsh-chat-manager>clsx': '^2.0.0', unrelated: 'custom' }, other: true },
dsh: { profile: { bundles: ['user-plugin'] } },
};
const f = fixture(t, custom);
writeFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'custom workspace');
writeFileSync(join(f.profile, 'cordis.patch.yml'), 'custom patch');
f.run();
const expected = structuredClone(custom);
expected.dependencies['dsh-chat-manager'] = BUILTIN_DEPENDENCIES['dsh-chat-manager'];
assert.deepEqual(JSON.parse(readFileSync(f.path)), expected);
assert.equal(readFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'utf8'), 'custom workspace');
assert.equal(readFileSync(join(f.profile, 'cordis.patch.yml'), 'utf8'), 'custom patch');
});
test('strict archive matching leaves non-semver references untouched', () => {
for (const version of ['latest', '01.0.0', '1.0', '1.0.0-01', '1.0.0.tgz/extra']) {
const input = { dependencies: { 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-' + version + '.tgz' } };
assert.equal(reconcileManifest(input).changed, false);
}
});
test('current defaults and user-only manifests remain byte-identical', t => {
for (const manifest of [
{ dependencies: BUILTIN_DEPENDENCIES, pnpm: { overrides: BUILTIN_OVERRIDES } },
{ dependencies: { 'dsh-chat-manager': '^1.0.0' }, custom: true },
{ dsh: { profile: { bundles: [] } } },
]) {
const f = fixture(t, manifest);
assert.equal(f.run().changed, false);
assert.equal(readFileSync(f.path, 'utf8'), f.original);
assert.deepEqual(readdirSync(f.profile), ['package.json']);
}
});
test('missing archives fail before writes, even when already current', t => {
for (const reference of [old('dsh-web-mobile'), BUILTIN_DEPENDENCIES['dsh-web-mobile']]) {
const f = fixture(t, { dependencies: { 'dsh-web-mobile': reference } });
rmSync(join(f.archiveDirectory, basename(BUILTIN_DEPENDENCIES['dsh-web-mobile'])));
assert.throws(f.run, /ENOENT/);
assert.equal(readFileSync(f.path, 'utf8'), f.original);
assert.deepEqual(readdirSync(f.profile), ['package.json']);
}
});
test('invalid JSON and malformed sections fail without changing original', t => {
for (const input of ['{ broken', 'null', '[]', JSON.stringify({ dependencies: [] }), JSON.stringify({ pnpm: { overrides: null } })]) {
const f = fixture(t, input);
assert.throws(f.run);
assert.equal(readFileSync(f.path, 'utf8'), input);
assert.deepEqual(readdirSync(f.profile), ['package.json']);
}
});
test('rejects manifest/workspace symlinks, dangling links and directories', t => {
for (const name of ['package.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', '.dsh-lockfile-pending']) {
for (const kind of ['symlink', 'dangling', 'directory']) {
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
const target = join(f.root, 'outside');
if (kind === 'symlink') writeFileSync(target, f.original);
const path = join(f.profile, name);
rmSync(path, { force: true });
if (kind === 'directory') mkdirSync(path);
else symlinkSync(target, path);
const before = readdirSync(f.profile);
assert.throws(f.run, /unsafe profile file/);
assert.deepEqual(readdirSync(f.profile), before);
if (kind === 'symlink') assert.equal(readFileSync(target, 'utf8'), f.original);
if (name !== 'package.json') assert.equal(readFileSync(f.path, 'utf8'), f.original);
}
}
});
test('rejects symlinked profile and ancestors', t => {
const f = fixture(t, '{}');
const link = join(f.root, 'linked');
symlinkSync(join(f.root, 'profiles'), link);
assert.throws(() => reconcileProfile(join(link, 'web'), { archiveDirectory: f.archiveDirectory }), /unsafe profile directory/);
const direct = join(f.root, 'direct');
symlinkSync(f.profile, direct);
assert.throws(() => reconcileProfile(direct), /unsafe profile directory/);
});
test('init migrates before restoring workspace and rejects unsafe workspace before writes', t => {
const f = fixture(t, { optionalDependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
const run = () => initProfile(f.root, { archiveDirectory: f.archiveDirectory });
symlinkSync(join(f.root, 'missing'), join(f.profile, 'pnpm-workspace.yaml'));
assert.throws(run, /unsafe profile file/);
assert.equal(readFileSync(f.path, 'utf8'), f.original);
rmSync(join(f.profile, 'pnpm-workspace.yaml'));
run();
assert.equal(JSON.parse(readFileSync(f.path)).optionalDependencies['dsh-web-mobile'], BUILTIN_DEPENDENCIES['dsh-web-mobile']);
assert.match(readFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'utf8'), /autoInstallPeers: false/);
});