Files
dsh/upstream/container/platform/test/user-plugin-transactions.test.mjs

440 lines
20 KiB
JavaScript

import assert from 'node:assert/strict'
import { chmod, lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { UserPluginInventory } from '../../control-plane/modules/plugin-manager/user-inventory.mjs'
import { UserPluginJournal, userPluginJournalInternals } from '../../control-plane/modules/plugin-manager/user-journal.mjs'
import { UserPluginSnapshots, userPluginSnapshotInternals } from '../../control-plane/modules/plugin-manager/user-snapshots.mjs'
import { UserPluginSelectionStore, userPluginStateInternals } from '../../control-plane/modules/plugin-manager/user-state.mjs'
import { UserPluginTransactionManager, userPluginTransactionInternals } from '../../control-plane/modules/plugin-manager/user-transaction.mjs'
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'dsh-user-plugin-transaction-'))
const dshHome = join(root, 'dsh')
const profileRoot = join(dshHome, 'profiles/web')
const stateRoot = join(root, 'platform/state/management')
const selectionPath = join(stateRoot, 'user-plugins.json')
const journalPath = join(stateRoot, 'user-plugin-transaction.json')
const snapshotsRoot = join(root, 'platform/store/snapshots/user-plugins')
await mkdir(join(profileRoot, 'node_modules'), { recursive: true })
await writeFile(join(profileRoot, 'package.json'), JSON.stringify({
name: 'dsh-profile-web',
dependencies: { alpha: '1.0.0', beta: '1.0.0', gamma: '1.0.0' },
dsh: { profile: { bundles: ['alpha', 'beta'] } },
}))
await writeFile(join(profileRoot, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
for (const name of ['alpha', 'beta', 'gamma']) {
const installed = join(profileRoot, 'node_modules', name)
await mkdir(installed)
await writeFile(join(installed, 'package.json'), JSON.stringify({
name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
await writeFile(join(installed, 'cordis.patch.yml'), '[]\n')
}
const inventory = new UserPluginInventory({ dshHome, selectionPath })
const selectionStore = new UserPluginSelectionStore(selectionPath)
const journal = new UserPluginJournal(journalPath)
const snapshots = new UserPluginSnapshots({ root: snapshotsRoot, profileRoot })
return { root, dshHome, profileRoot, selectionPath, journal, snapshots, inventory, selectionStore }
}
function manager(value, overrides = {}) {
const calls = []
return {
calls,
value: new UserPluginTransactionManager({
inventory: value.inventory,
selectionStore: value.selectionStore,
snapshots: value.snapshots,
journal: value.journal,
pauseDsh: async () => { calls.push('pause') },
restartDsh: async () => { calls.push('restart') },
runPlugin: async details => { calls.push(['remove', details]) },
report: async message => { calls.push(['report', message]) },
...overrides,
}),
}
}
test('runs DSH plugin removal with writable user configuration directories', () => {
const previous = {
HOME: process.env.HOME,
npm_config_userconfig: process.env.npm_config_userconfig,
NPM_CONFIG_CACHE: process.env.NPM_CONFIG_CACHE,
PNPM_HOME: process.env.PNPM_HOME,
npm_config_prefix: process.env.npm_config_prefix,
}
process.env.HOME = '/root'
process.env.npm_config_userconfig = '/root/.npmrc'
process.env.NPM_CONFIG_CACHE = '/root/.npm'
process.env.PNPM_HOME = '/root/.local/share/pnpm'
process.env.npm_config_prefix = '/root/.local'
try {
const environment = userPluginTransactionInternals.pluginEnvironment('/data/dsh')
assert.equal(environment.HOME, '/home/node')
assert.equal(environment.XDG_CACHE_HOME, '/home/node/.cache')
assert.equal(environment.XDG_CONFIG_HOME, '/home/node/.config')
assert.equal(environment.XDG_DATA_HOME, '/home/node/.local/share')
assert.equal(environment.XDG_STATE_HOME, '/home/node/.local/state')
assert.equal(environment.DSH_HOME, '/data/dsh')
assert.equal(environment.npm_config_userconfig, undefined)
assert.equal(environment.NPM_CONFIG_CACHE, undefined)
assert.equal(environment.PNPM_HOME, undefined)
assert.equal(environment.npm_config_prefix, undefined)
} finally {
for (const [name, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[name]
else process.env[name] = value
}
}
})
test('snapshots and restores the complete Web Profile with hidden files, modes, and symlinks', async () => {
const value = await fixture()
await writeFile(join(value.profileRoot, '.hidden'), 'hidden\n')
await chmod(join(value.profileRoot, '.hidden'), 0o640)
await symlink('package.json', join(value.profileRoot, 'manifest-link'))
await value.snapshots.create('snapshot-one')
await rm(join(value.profileRoot, '.hidden'))
await writeFile(join(value.profileRoot, 'package.json'), '{}\n')
await value.snapshots.restore('snapshot-one')
assert.equal(await readFile(join(value.profileRoot, '.hidden'), 'utf8'), 'hidden\n')
assert.equal((await lstat(join(value.profileRoot, '.hidden'))).mode & 0o777, 0o640)
assert.equal(await readlink(join(value.profileRoot, 'manifest-link')), 'package.json')
assert.match(await readFile(join(value.profileRoot, 'package.json'), 'utf8'), /dsh-profile-web/)
await writeFile(join(value.snapshots.path('snapshot-one'), 'profile.tar'), 'corrupt')
await assert.rejects(value.snapshots.inspect('snapshot-one'), /does not match/)
})
test('creates full Profile snapshots without compression overhead', async () => {
const value = await fixture()
await value.snapshots.create('snapshot-uncompressed')
assert.equal((await lstat(join(value.snapshots.path('snapshot-uncompressed'), 'profile.tar'))).isFile(), true)
await assert.rejects(lstat(join(value.snapshots.path('snapshot-uncompressed'), 'profile.tar.gz')), { code: 'ENOENT' })
})
test('atomically applies ordered enable and disable actions around one DSH pause', async () => {
const value = await fixture()
const before = await value.inventory.read()
const transaction = manager(value)
const result = await transaction.value.apply({
taskId: 'task-enable-disable',
revision: before.revision,
actions: [{ name: 'alpha', action: 'disable' }, { name: 'gamma', action: 'enable' }],
})
assert.equal(result.status, 'success')
const manifest = JSON.parse(await readFile(join(value.profileRoot, 'package.json'), 'utf8'))
assert.deepEqual(manifest.dsh.profile.bundles, ['beta', 'gamma'])
assert.deepEqual((await value.selectionStore.read()).disabled, [{ name: 'alpha', index: 0 }])
assert.deepEqual(result.inventory.plugins.map(plugin => [plugin.name, plugin.enabled]), [
['alpha', false], ['beta', true], ['gamma', true],
])
assert.equal((await value.journal.read()).phase, 'completed')
await assert.rejects(lstat(value.snapshots.path('task-enable-disable')), { code: 'ENOENT' })
assert.deepEqual(transaction.calls.filter(call => typeof call === 'string'), ['pause', 'restart'])
})
test('records every disabled plugin position from the original Bundle order', async () => {
const value = await fixture()
const before = await value.inventory.read()
const transaction = manager(value)
await transaction.value.apply({
taskId: 'task-disable-order', revision: before.revision,
actions: [{ name: 'alpha', action: 'disable' }, { name: 'beta', action: 'disable' }],
})
assert.deepEqual((await value.selectionStore.read()).disabled, [
{ name: 'alpha', index: 0 }, { name: 'beta', index: 1 },
])
})
test('restores the full Profile when an exact uninstall command fails before commit', async () => {
const value = await fixture()
const beforeBytes = await readFile(join(value.profileRoot, 'package.json'))
const before = await value.inventory.read()
const transaction = manager(value, {
runPlugin: async ({ name }) => {
await writeFile(join(value.profileRoot, 'package.json'), '{}\n')
throw new Error(`pnpm failed for ${name}`)
},
})
await assert.rejects(transaction.value.apply({
taskId: 'task-uninstall-failure',
revision: before.revision,
actions: [{ name: 'alpha', action: 'uninstall' }],
}), /pnpm failed/)
assert.deepEqual(await readFile(join(value.profileRoot, 'package.json')), beforeBytes)
assert.equal((await value.journal.read()).phase, 'failed')
assert.equal((await value.journal.read()).recoveryResult, 'success')
assert.deepEqual(transaction.calls.filter(call => typeof call === 'string'), ['pause', 'restart'])
})
test('uninstalls one exact package name without exposing package-manager arguments', async () => {
const value = await fixture()
const before = await value.inventory.read()
const removals = []
const transaction = manager(value, {
runPlugin: async details => {
removals.push(details)
const manifestPath = join(value.profileRoot, 'package.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
delete manifest.dependencies[details.name]
manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter(name => name !== details.name)
await writeFile(manifestPath, JSON.stringify(manifest))
await writeFile(join(value.profileRoot, 'pnpm-lock.yaml'), 'lockfileVersion: 9\npackages: {}\n')
await rm(join(value.profileRoot, 'node_modules', details.name), { recursive: true, force: true })
},
})
const result = await transaction.value.apply({
taskId: 'task-uninstall-success',
revision: before.revision,
actions: [{ name: 'alpha', action: 'uninstall' }],
})
assert.deepEqual(removals, [{ dshHome: value.dshHome, profile: 'web', name: 'alpha' }])
assert.equal(result.inventory.plugins.some(plugin => plugin.name === 'alpha'), false)
})
test('reapplies disabled Bundle selection after DSH plugin removal reconciles the Profile', async () => {
const value = await fixture()
const before = await value.inventory.read()
const transaction = manager(value, {
runPlugin: async details => {
const manifestPath = join(value.profileRoot, 'package.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
delete manifest.dependencies[details.name]
manifest.dsh.profile.bundles = ['beta']
await writeFile(manifestPath, JSON.stringify(manifest))
await rm(join(value.profileRoot, 'node_modules', details.name), { recursive: true, force: true })
},
})
const result = await transaction.value.apply({
taskId: 'task-disable-and-uninstall',
revision: before.revision,
actions: [{ name: 'beta', action: 'disable' }, { name: 'alpha', action: 'uninstall' }],
})
const manifest = JSON.parse(await readFile(join(value.profileRoot, 'package.json'), 'utf8'))
assert.deepEqual(manifest.dsh.profile.bundles, [])
assert.equal(result.inventory.plugins.find(plugin => plugin.name === 'beta')?.enabled, false)
assert.equal(result.inventory.plugins.some(plugin => plugin.name === 'alpha'), false)
})
test('restarts unchanged DSH when the mandatory Profile snapshot cannot be created', async () => {
const value = await fixture()
const beforeBytes = await readFile(join(value.profileRoot, 'package.json'))
const before = await value.inventory.read()
const transaction = manager(value, {
snapshots: {
create: async () => { throw new Error('snapshot disk full') },
remove: async () => {},
},
})
await assert.rejects(transaction.value.apply({
taskId: 'task-snapshot-failure',
revision: before.revision,
actions: [{ name: 'alpha', action: 'disable' }],
}), /snapshot disk full/)
assert.deepEqual(await readFile(join(value.profileRoot, 'package.json')), beforeBytes)
assert.deepEqual(transaction.calls.filter(call => typeof call === 'string'), ['pause', 'restart'])
const journal = await value.journal.read()
assert.equal(journal.phase, 'failed')
assert.equal(journal.recoveryResult, 'success')
})
test('retains a committed plugin change when DSH still fails to restart', async () => {
const value = await fixture()
const before = await value.inventory.read()
const transaction = manager(value, { restartDsh: async () => { throw new Error('DSH remains broken') } })
const result = await transaction.value.apply({
taskId: 'task-restart-failure',
revision: before.revision,
actions: [{ name: 'alpha', action: 'disable' }],
})
assert.equal(result.status, 'success')
assert.equal(result.strategy, 'next-start')
assert.equal(result.activationError, 'DSH remains broken')
const manifest = JSON.parse(await readFile(join(value.profileRoot, 'package.json'), 'utf8'))
assert.deepEqual(manifest.dsh.profile.bundles, ['beta'])
const journal = await value.journal.read()
assert.equal(journal.phase, 'completed')
assert.equal(journal.recoveryResult, null)
await assert.rejects(lstat(value.snapshots.path('task-restart-failure')), { code: 'ENOENT' })
})
test('applies persistent plugin changes without touching DSH when it is unavailable', async () => {
const value = await fixture()
const before = await value.inventory.read()
const transaction = manager(value)
const result = await transaction.value.apply({
taskId: 'task-next-start',
revision: before.revision,
actions: [{ name: 'alpha', action: 'disable' }],
strategy: 'next-start',
})
assert.equal(result.status, 'success')
assert.equal(result.strategy, 'next-start')
assert.equal(result.activationError, null)
assert.deepEqual(transaction.calls.filter(call => typeof call === 'string'), [])
assert.deepEqual(JSON.parse(await readFile(join(value.profileRoot, 'package.json'), 'utf8')).dsh.profile.bundles, ['beta'])
assert.equal((await value.journal.read()).phase, 'completed')
})
test('recovers an interrupted pre-commit mutation from its persisted snapshot', async () => {
const value = await fixture()
const before = await value.inventory.read()
await value.journal.begin({
taskId: 'task-interrupted', revision: before.revision,
actions: [{ name: 'alpha', action: 'disable' }],
selection: await value.selectionStore.snapshot(),
})
await value.journal.transition('paused')
await value.snapshots.create('task-interrupted')
await value.journal.transition('snapshotted', { snapshotId: 'task-interrupted' })
await value.journal.transition('mutating')
await writeFile(join(value.profileRoot, 'package.json'), '{}\n')
const transaction = manager(value)
const recovered = await transaction.value.recover()
assert.equal(recovered.phase, 'failed')
assert.equal(recovered.recoveryResult, 'success')
assert.match(await readFile(join(value.profileRoot, 'package.json'), 'utf8'), /dsh-profile-web/)
await assert.rejects(lstat(value.selectionPath), { code: 'ENOENT' })
assert.deepEqual(transaction.calls.filter(call => typeof call === 'string'), ['pause', 'restart'])
})
test('restores an interrupted pre-commit Profile before Bootstrap starts DSH', async () => {
const value = await fixture()
const before = await value.inventory.read()
await value.journal.begin({
taskId: 'task-prestart-restore', revision: before.revision,
actions: [{ name: 'alpha', action: 'disable' }],
selection: await value.selectionStore.snapshot(),
})
await value.journal.transition('paused')
await value.snapshots.create('task-prestart-restore')
await value.journal.transition('snapshotted', { snapshotId: 'task-prestart-restore' })
await value.journal.transition('mutating')
await writeFile(join(value.profileRoot, 'package.json'), '{}\n')
const transaction = manager(value)
const recovered = await transaction.value.recoverBeforeDshStart()
assert.equal(recovered.phase, 'failed')
assert.equal(recovered.recoveryResult, 'success')
assert.match(await readFile(join(value.profileRoot, 'package.json'), 'utf8'), /dsh-profile-web/)
assert.deepEqual(transaction.calls, [])
})
test('lets Bootstrap start a committed Profile once and then finalizes the journal', async () => {
const value = await fixture()
const before = await value.inventory.read()
await value.journal.begin({
taskId: 'task-prestart-committed', revision: before.revision,
actions: [{ name: 'alpha', action: 'disable' }],
selection: await value.selectionStore.snapshot(),
})
await value.journal.transition('paused')
await value.snapshots.create('task-prestart-committed')
await value.journal.transition('snapshotted', { snapshotId: 'task-prestart-committed' })
await value.journal.transition('mutating')
await value.journal.transition('committed')
const transaction = manager(value)
const pending = await transaction.value.recoverBeforeDshStart()
assert.equal(pending.phase, 'restarting')
assert.deepEqual(transaction.calls, [])
const completed = await transaction.value.completeDshStartup({ healthy: true })
assert.equal(completed.phase, 'completed')
await assert.rejects(lstat(value.snapshots.path('task-prestart-committed')), { code: 'ENOENT' })
})
test('restores platform selection state when final Profile validation fails', async () => {
const value = await fixture()
await value.selectionStore.write({ schema: 1, disabled: [{ name: 'gamma', index: 2 }] })
const initial = await value.inventory.read()
let reads = 0
const inventory = Object.create(value.inventory)
inventory.read = async () => {
reads += 1
if (reads === 2) throw new Error('result validation failed')
return value.inventory.read()
}
const transaction = manager({ ...value, inventory })
await assert.rejects(transaction.value.apply({
taskId: 'task-selection-restore',
revision: initial.revision,
actions: [{ name: 'alpha', action: 'disable' }],
}), /result validation failed/)
assert.deepEqual((await value.selectionStore.read()).disabled, [{ name: 'gamma', index: 2 }])
const manifest = JSON.parse(await readFile(join(value.profileRoot, 'package.json'), 'utf8'))
assert.deepEqual(manifest.dsh.profile.bundles, ['alpha', 'beta'])
})
test('rejects stale revisions and reserved-name enablement before pausing DSH', async () => {
const value = await fixture()
const transaction = manager(value)
await assert.rejects(transaction.value.apply({
revision: `sha256:${'0'.repeat(64)}`,
actions: [{ name: 'alpha', action: 'disable' }],
}), error => error.code === 'REVISION_CONFLICT')
assert.deepEqual(transaction.calls, [])
const reserved = new UserPluginInventory({
dshHome: value.dshHome,
selectionPath: value.selectionPath,
systemPluginNames: async () => ['alpha'],
})
const reservedManager = manager({ ...value, inventory: reserved })
const current = await reserved.read()
await assert.rejects(reservedManager.value.apply({
revision: current.revision,
actions: [{ name: 'alpha', action: 'enable' }],
}), /cannot be enabled/)
assert.deepEqual(reservedManager.calls, [])
})
test('rejects extension fields in every persisted User Plugin state document', () => {
assert.throws(() => userPluginStateInternals.parse({ schema: 1, disabled: [], extra: true }), /invalid/)
assert.throws(() => userPluginSnapshotInternals.parseManifest({
schema: 1,
id: 'snapshot',
source: '/data/dsh/profiles/web',
createdAt: new Date(0).toISOString(),
archiveSha256: 'a'.repeat(64),
archiveSize: 1,
extra: true,
}), /invalid/)
assert.throws(() => userPluginJournalInternals.parse({
schema: 1,
taskId: 'task',
revision: `sha256:${'a'.repeat(64)}`,
actions: [{ name: 'plugin', action: 'disable' }],
selectionPresent: false,
previousDisabled: [],
phase: 'validated',
snapshotId: null,
error: null,
recoveryResult: null,
updatedAt: new Date(0).toISOString(),
extra: true,
}), /invalid/)
})
test('does not turn successful recovery into failure when snapshot cleanup fails', async () => {
const value = await fixture()
const before = await value.inventory.read()
const reports = []
const snapshots = {
...value.snapshots,
create: (...args) => value.snapshots.create(...args),
restore: (...args) => value.snapshots.restore(...args),
remove: async () => { throw new Error('cleanup failed') },
}
const transaction = manager(value, {
snapshots,
report: async message => { reports.push(message) },
})
const result = await transaction.value.apply({
taskId: 'task-cleanup-warning', revision: before.revision,
actions: [{ name: 'alpha', action: 'disable' }],
})
assert.equal(result.status, 'success')
assert.equal((await value.journal.read()).phase, 'completed')
assert.equal(reports.includes('user-plugin.snapshot.cleanup.failed'), true)
})