fix: harden prerender publication boundary
This commit is contained in:
139
tests/integration/publication.test.ts
Normal file
139
tests/integration/publication.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { mkdtemp, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
captureDirectoryIdentity,
|
||||
publishStagedDirectory,
|
||||
} from '../../scripts/safe-publication.mjs';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
const repository = async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'portfolio-publication-'));
|
||||
temporaryDirectories.push(root);
|
||||
await mkdir(join(root, 'dist'));
|
||||
await mkdir(join(root, '.dist-stage-test'));
|
||||
await writeFile(join(root, 'dist/old.txt'), 'old');
|
||||
await writeFile(join(root, '.dist-stage-test/new.txt'), 'new');
|
||||
return {
|
||||
root,
|
||||
finalRoot: join(root, 'dist'),
|
||||
stagingRoot: join(root, '.dist-stage-test'),
|
||||
};
|
||||
};
|
||||
|
||||
describe('safe artifact publication', () => {
|
||||
it('rejects a symlinked final output before mutating its target', async () => {
|
||||
const { root, finalRoot, stagingRoot } = await repository();
|
||||
const outside = await mkdtemp(join(tmpdir(), 'portfolio-publication-outside-'));
|
||||
temporaryDirectories.push(outside);
|
||||
await writeFile(join(outside, 'protected.txt'), 'protected');
|
||||
await rm(finalRoot, { recursive: true });
|
||||
await symlink(outside, finalRoot);
|
||||
|
||||
await expect(publishStagedDirectory({
|
||||
repositoryRoot: root,
|
||||
finalRoot,
|
||||
stagingRoot,
|
||||
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
|
||||
})).rejects.toThrow(/symlink|safe directory/i);
|
||||
expect(await readFile(join(outside, 'protected.txt'), 'utf8')).toBe('protected');
|
||||
});
|
||||
|
||||
it('rejects an output path outside the repository', async () => {
|
||||
const { root, stagingRoot } = await repository();
|
||||
const outside = await mkdtemp(join(tmpdir(), 'portfolio-publication-outside-'));
|
||||
temporaryDirectories.push(outside);
|
||||
|
||||
await expect(publishStagedDirectory({
|
||||
repositoryRoot: root,
|
||||
finalRoot: outside,
|
||||
stagingRoot,
|
||||
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
|
||||
})).rejects.toThrow(/outside.*repository/i);
|
||||
});
|
||||
|
||||
it('rejects a repository path with a symlinked ancestor', async () => {
|
||||
const { root, stagingRoot } = await repository();
|
||||
const parent = await mkdtemp(join(tmpdir(), 'portfolio-publication-link-'));
|
||||
temporaryDirectories.push(parent);
|
||||
const linkedRoot = join(parent, 'linked-root');
|
||||
await symlink(root, linkedRoot);
|
||||
|
||||
await expect(publishStagedDirectory({
|
||||
repositoryRoot: linkedRoot,
|
||||
finalRoot: join(linkedRoot, 'dist'),
|
||||
stagingRoot,
|
||||
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
|
||||
})).rejects.toThrow(/ancestor|canonical|symlink/i);
|
||||
});
|
||||
|
||||
it('rejects a staging directory replaced after verification without touching the final output', async () => {
|
||||
const { root, finalRoot, stagingRoot } = await repository();
|
||||
const expectedStagingIdentity = await captureDirectoryIdentity(stagingRoot);
|
||||
await rm(stagingRoot, { recursive: true });
|
||||
await mkdir(stagingRoot);
|
||||
await writeFile(join(stagingRoot, 'attacker.txt'), 'attacker');
|
||||
|
||||
await expect(publishStagedDirectory({ repositoryRoot: root, finalRoot, stagingRoot, expectedStagingIdentity }))
|
||||
.rejects.toThrow(/identity changed/i);
|
||||
expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old');
|
||||
});
|
||||
|
||||
it('rolls back the previous output when the staged rename fails mid-publication', async () => {
|
||||
const { root, finalRoot, stagingRoot } = await repository();
|
||||
let renameCount = 0;
|
||||
const failSecondRename = async (from: string, to: string) => {
|
||||
renameCount += 1;
|
||||
if (renameCount === 2) throw new Error('injected staged rename failure');
|
||||
await rename(from, to);
|
||||
};
|
||||
|
||||
await expect(publishStagedDirectory({
|
||||
repositoryRoot: root,
|
||||
finalRoot,
|
||||
stagingRoot,
|
||||
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
|
||||
renameDirectory: failSecondRename,
|
||||
})).rejects.toThrow(/injected staged rename failure/i);
|
||||
expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old');
|
||||
await expect(readFile(join(finalRoot, 'new.txt'), 'utf8')).rejects.toThrow();
|
||||
expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls back the previous output when the pre-commit authority update fails', async () => {
|
||||
const { root, finalRoot, stagingRoot } = await repository();
|
||||
|
||||
await expect(publishStagedDirectory({
|
||||
repositoryRoot: root,
|
||||
finalRoot,
|
||||
stagingRoot,
|
||||
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
|
||||
beforeCommit: async () => {
|
||||
throw new Error('injected authority update failure');
|
||||
},
|
||||
})).rejects.toThrow(/injected authority update failure/i);
|
||||
expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old');
|
||||
await expect(readFile(join(finalRoot, 'new.txt'), 'utf8')).rejects.toThrow();
|
||||
expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false);
|
||||
});
|
||||
|
||||
it('publishes the verified staging directory and removes the superseded output', async () => {
|
||||
const { root, finalRoot, stagingRoot } = await repository();
|
||||
|
||||
await publishStagedDirectory({
|
||||
repositoryRoot: root,
|
||||
finalRoot,
|
||||
stagingRoot,
|
||||
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
|
||||
});
|
||||
|
||||
expect(await readFile(join(finalRoot, 'new.txt'), 'utf8')).toBe('new');
|
||||
await expect(readFile(join(finalRoot, 'old.txt'), 'utf8')).rejects.toThrow();
|
||||
expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user