Files
Portfolio/tests/integration/publication.test.ts

140 lines
5.7 KiB
TypeScript

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);
});
});