test(#1297): make broker-dependent tests host-independent (CI root + no live broker)
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
Pipeline 2561 failed 5 tests that pass on a dev host, two环境 classes: 1. Un-seamed success-path start/apply tests answered the HOST's broker state: this dev machine has a live lease-broker socket at the default XDG path, so the real probe (correctly, since #1297 F3) returned true locally; CI has no broker, so the new start re-check refused broker-absent. Fixed by injecting a deterministic present-broker seam in exactly the tests whose property is ordering/targeting (reconciler start-ordering, two apply-running tests, and the CLI command spec's shared helper). Refusal and real-probe tests keep explicit false seams / explicit socket paths. 2. The placeUnitFile unlink-failure spec relies on EACCES from a 0500 directory; CI steps run as root (apk add succeeds unprivileged-free) and CAP_DAC_OVERRIDE makes unlink succeed, so the abort path cannot be triggered there. Now it.skip under uid 0 with the reason stated; exercised on every non-root dev host. The remaining unseamed apply tests use the stopped-agent path (no broker interaction) and passed CI unchanged — verified against the 2561 log, not assumed.
This commit is contained in:
@@ -73,6 +73,10 @@ function program(
|
|||||||
runner,
|
runner,
|
||||||
reconcileDeps: {
|
reconcileDeps: {
|
||||||
homeDirectory: '/home/mosaic',
|
homeDirectory: '/home/mosaic',
|
||||||
|
// Deterministic broker presence: without a seam the reconciler probes the
|
||||||
|
// REAL host socket (#1297 F3), making every CLI start test answer the
|
||||||
|
// host's broker state instead of its own property.
|
||||||
|
checkBrokerSocket: async () => true,
|
||||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||||
validateRoster: async () => undefined,
|
validateRoster: async () => undefined,
|
||||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||||
|
|||||||
@@ -4516,29 +4516,40 @@ describe('#1297 review: the real broker probe, exercised without any seam', () =
|
|||||||
await rm(dir, { recursive: true, force: true });
|
await rm(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('placeUnitFile aborts with UnitPlacementError when unlink fails — never copies through a live symlink', async () => {
|
// EACCES-based unlink failure requires a non-root uid: root bypasses
|
||||||
const dir = await tempDir();
|
// directory mode bits (CAP_DAC_OVERRIDE), so the abort path cannot be
|
||||||
const unitDir = join(dir, 'systemd', 'user');
|
// triggered this way under CI's root runner. Skipped there, exercised on
|
||||||
await mkdir(unitDir, { recursive: true });
|
// every non-root dev host.
|
||||||
// By-path residue: destination is a symlink pointing somewhere else.
|
const itUnlessRoot =
|
||||||
const residueTarget = join(dir, 'residue-target');
|
typeof process.getuid === 'function' && process.getuid() === 0 ? it.skip : it;
|
||||||
await writeFile(residueTarget, 'RESIDUE-BYTES');
|
itUnlessRoot(
|
||||||
const destination = join(unitDir, 'x.service');
|
'placeUnitFile aborts with UnitPlacementError when unlink fails — never copies through a live symlink',
|
||||||
await symlink(residueTarget, destination);
|
async () => {
|
||||||
const source = join(dir, 'seed.service');
|
const dir = await tempDir();
|
||||||
await writeFile(source, 'UNIT-BYTES');
|
const unitDir = join(dir, 'systemd', 'user');
|
||||||
// Read-only unit dir: unlink now fails EACCES (test runs as the owner,
|
await mkdir(unitDir, { recursive: true });
|
||||||
// not root, so mode bits are enforced).
|
// By-path residue: destination is a symlink pointing somewhere else.
|
||||||
await chmod(unitDir, 0o500);
|
const residueTarget = join(dir, 'residue-target');
|
||||||
try {
|
await writeFile(residueTarget, 'RESIDUE-BYTES');
|
||||||
await expect(placeUnitFile(source, unitDir, 'x.service')).rejects.toThrow(UnitPlacementError);
|
const destination = join(unitDir, 'x.service');
|
||||||
} finally {
|
await symlink(residueTarget, destination);
|
||||||
await chmod(unitDir, 0o700);
|
const source = join(dir, 'seed.service');
|
||||||
}
|
await writeFile(source, 'UNIT-BYTES');
|
||||||
// The copy-through never happened: residue bytes intact, destination
|
// Read-only unit dir: unlink now fails EACCES (test runs as the owner,
|
||||||
// still the symlink (abort, not overwrite-through).
|
// not root, so mode bits are enforced).
|
||||||
expect(await readFile(residueTarget, 'utf8')).toBe('RESIDUE-BYTES');
|
await chmod(unitDir, 0o500);
|
||||||
expect(await readlink(destination)).toBe(residueTarget);
|
try {
|
||||||
await rm(dir, { recursive: true, force: true });
|
await expect(placeUnitFile(source, unitDir, 'x.service')).rejects.toThrow(
|
||||||
});
|
UnitPlacementError,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await chmod(unitDir, 0o700);
|
||||||
|
}
|
||||||
|
// The copy-through never happened: residue bytes intact, destination
|
||||||
|
// still the symlink (abort, not overwrite-through).
|
||||||
|
expect(await readFile(residueTarget, 'utf8')).toBe('RESIDUE-BYTES');
|
||||||
|
expect(await readlink(destination)).toBe(residueTarget);
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -180,6 +180,10 @@ describe('fleet roster-owned reconciler', (): void => {
|
|||||||
it('command start enables and starts the broker BEFORE the holder and any agent unit', async (): Promise<void> => {
|
it('command start enables and starts the broker BEFORE the holder and any agent unit', async (): Promise<void> => {
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
const result = await run('start', {
|
const result = await run('start', {
|
||||||
|
// Deterministic broker presence: without the seam this test answers the
|
||||||
|
// HOST's broker state (passes on a machine with a live broker, refuses
|
||||||
|
// on CI), not the ordering property it exists for (#1297 follow-up).
|
||||||
|
checkBrokerSocket: async () => true,
|
||||||
runner: async (command, args) => {
|
runner: async (command, args) => {
|
||||||
calls.push([command, ...args]);
|
calls.push([command, ...args]);
|
||||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||||
@@ -232,6 +236,8 @@ describe('fleet roster-owned reconciler', (): void => {
|
|||||||
expectedGeneration: 7,
|
expectedGeneration: 7,
|
||||||
deps: deps({
|
deps: deps({
|
||||||
readRoster: async () => runningRoster,
|
readRoster: async () => runningRoster,
|
||||||
|
// Deterministic broker presence (see start-ordering test note).
|
||||||
|
checkBrokerSocket: async () => true,
|
||||||
runner: async (command, args) => {
|
runner: async (command, args) => {
|
||||||
calls.push([command, ...args]);
|
calls.push([command, ...args]);
|
||||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||||
@@ -543,6 +549,8 @@ describe('fleet roster-owned reconciler', (): void => {
|
|||||||
expectedGeneration: 7,
|
expectedGeneration: 7,
|
||||||
deps: deps({
|
deps: deps({
|
||||||
readRoster: async () => runningRoster,
|
readRoster: async () => runningRoster,
|
||||||
|
// Deterministic broker presence (see start-ordering test note).
|
||||||
|
checkBrokerSocket: async () => true,
|
||||||
runner: async (command, args) => {
|
runner: async (command, args) => {
|
||||||
calls.push([command, ...args]);
|
calls.push([command, ...args]);
|
||||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user