275 lines
11 KiB
JavaScript
275 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import { runPreflight, sourceFingerprint } from './preflight.mjs';
|
|
|
|
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`);
|
|
|
|
const requiredBins = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
|
|
|
async function fixture(name) {
|
|
const root = path.join(fixtureRoot, name);
|
|
await mkdir(path.join(root, 'apps', 'web', 'src', 'app'), { recursive: true });
|
|
await writeFile(path.join(root, 'apps', 'web', 'src', 'app', 'page.tsx'), 'export default 1;\n');
|
|
return root;
|
|
}
|
|
|
|
async function installRequiredBins(root) {
|
|
const binDir = path.join(root, 'node_modules', '.bin');
|
|
await mkdir(binDir, { recursive: true });
|
|
await Promise.all(
|
|
requiredBins.map(async (name) => {
|
|
const target = path.join(binDir, name);
|
|
await writeFile(target, '');
|
|
await chmod(target, 0o755);
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function certifyGeneratedState(root, links = []) {
|
|
const nextDir = path.join(root, 'apps', 'web', '.next');
|
|
await mkdir(nextDir, { recursive: true });
|
|
const manifest = `${JSON.stringify({ version: 1, links })}\n`;
|
|
const manifestHash = createHash('sha256').update(manifest).digest('hex');
|
|
await writeFile(path.join(nextDir, '.mosaic-symlink-manifest'), manifest);
|
|
await writeFile(
|
|
path.join(nextDir, '.mosaic-source-hash'),
|
|
`${JSON.stringify({
|
|
version: 1,
|
|
sourceFingerprint: await sourceFingerprint(root),
|
|
symlinkManifestHash: manifestHash,
|
|
})}\n`,
|
|
);
|
|
}
|
|
|
|
test.after(async () => {
|
|
await rm(fixtureRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test('missing dependencies have a dedicated exit code and install remediation', async () => {
|
|
const root = await fixture('missing-deps');
|
|
const result = await runPreflight({ root });
|
|
|
|
assert.equal(result.code, 42);
|
|
assert.match(result.message, /MOSAIC_PREFLIGHT_MISSING_DEPS/);
|
|
assert.match(result.message, /run pnpm install/i);
|
|
});
|
|
|
|
test('a partial dependency install keeps the dedicated missing-deps result', async () => {
|
|
const root = await fixture('partial-deps');
|
|
await mkdir(path.join(root, 'node_modules', '.bin'), { recursive: true });
|
|
await writeFile(path.join(root, 'node_modules', '.bin', 'tsc'), '', { mode: 0o755 });
|
|
|
|
const result = await runPreflight({ root });
|
|
assert.equal(result.code, 42);
|
|
assert.match(result.message, /turbo/);
|
|
});
|
|
|
|
test('a dangling required dependency shim keeps the dedicated missing-deps result', async () => {
|
|
const root = await fixture('dangling-deps');
|
|
await installRequiredBins(root);
|
|
const turbo = path.join(root, 'node_modules', '.bin', 'turbo');
|
|
await rm(turbo);
|
|
await symlink(path.join(root, 'node_modules', 'missing-turbo'), turbo);
|
|
|
|
const result = await runPreflight({ root });
|
|
assert.equal(result.code, 42);
|
|
assert.match(result.message, /turbo/);
|
|
});
|
|
|
|
test('installed dependencies pass when generated state is absent', async () => {
|
|
const root = await fixture('clean');
|
|
await installRequiredBins(root);
|
|
|
|
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
|
});
|
|
|
|
test('foreign-owned generated Next state is identified separately from source errors', async () => {
|
|
const root = await fixture('foreign-next');
|
|
await installRequiredBins(root);
|
|
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
|
await mkdir(path.dirname(generated), { recursive: true });
|
|
await writeFile(generated, 'generated output');
|
|
|
|
const result = await runPreflight({ root, uid: (process.getuid?.() ?? 0) + 1 });
|
|
assert.equal(result.code, 43);
|
|
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
|
assert.match(result.message, /foreign-owned/);
|
|
});
|
|
|
|
test('a generated marker mismatch is identified separately from source errors', async () => {
|
|
const root = await fixture('stale-next');
|
|
await installRequiredBins(root);
|
|
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
|
await mkdir(path.dirname(generated), { recursive: true });
|
|
await writeFile(generated, 'stale generated output');
|
|
await writeFile(path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'), 'old-source');
|
|
|
|
const result = await runPreflight({ root });
|
|
assert.equal(result.code, 43);
|
|
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
|
assert.match(result.message, /apps\/web\/\.next/);
|
|
assert.match(result.message, /pnpm clean:generated/);
|
|
});
|
|
|
|
test('generated-state symbolic links are accepted only when exactly build-certified', async (t) => {
|
|
await t.test('apps/web/.next itself is rejected when it is a symbolic link', async () => {
|
|
const root = await fixture('symbolic-next-root');
|
|
await installRequiredBins(root);
|
|
await writeFile(path.join(root, 'outside-generated'), 'not a Next build\n');
|
|
await symlink(path.join(root, 'outside-generated'), path.join(root, 'apps', 'web', '.next'));
|
|
|
|
const result = await runPreflight({ root });
|
|
assert.equal(result.code, 43);
|
|
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
|
assert.match(result.message, /symbolic link/);
|
|
});
|
|
|
|
await t.test('apps/web/.next is rejected when it is not a directory', async () => {
|
|
const root = await fixture('non-directory-next-root');
|
|
await installRequiredBins(root);
|
|
await writeFile(path.join(root, 'apps', 'web', '.next'), 'not a Next build\n');
|
|
|
|
const result = await runPreflight({ root });
|
|
assert.equal(result.code, 43);
|
|
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
|
assert.match(result.message, /real directory/);
|
|
});
|
|
|
|
await t.test('an added descendant symlink is rejected', async () => {
|
|
const root = await fixture('symbolic-next-added');
|
|
await installRequiredBins(root);
|
|
await certifyGeneratedState(root);
|
|
await symlink('/etc/hosts', path.join(root, 'apps', 'web', '.next', 'reviewer-symlink'));
|
|
|
|
const result = await runPreflight({ root });
|
|
assert.equal(result.code, 43);
|
|
assert.match(result.message, /symbolic-link manifest/);
|
|
});
|
|
|
|
await t.test('a removed certified descendant symlink is rejected', async () => {
|
|
const root = await fixture('symbolic-next-removed');
|
|
await installRequiredBins(root);
|
|
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
|
await mkdir(path.dirname(link), { recursive: true });
|
|
await symlink('../dependency-one', link);
|
|
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
|
await rm(link);
|
|
|
|
const result = await runPreflight({ root });
|
|
assert.equal(result.code, 43);
|
|
assert.match(result.message, /symbolic-link manifest/);
|
|
});
|
|
|
|
await t.test('a retargeted certified descendant symlink is rejected', async () => {
|
|
const root = await fixture('symbolic-next-retargeted');
|
|
await installRequiredBins(root);
|
|
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
|
await mkdir(path.dirname(link), { recursive: true });
|
|
await symlink('../dependency-one', link);
|
|
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
|
await rm(link);
|
|
await symlink('../dependency-two', link);
|
|
|
|
const result = await runPreflight({ root });
|
|
assert.equal(result.code, 43);
|
|
assert.match(result.message, /symbolic-link manifest/);
|
|
});
|
|
|
|
await t.test('a manifest edited to whitelist a rogue symlink is rejected', async () => {
|
|
const root = await fixture('symbolic-next-tampered-manifest');
|
|
await installRequiredBins(root);
|
|
await certifyGeneratedState(root);
|
|
const nextDir = path.join(root, 'apps', 'web', '.next');
|
|
await symlink('/etc/hosts', path.join(nextDir, 'reviewer-symlink'));
|
|
await writeFile(
|
|
path.join(nextDir, '.mosaic-symlink-manifest'),
|
|
`${JSON.stringify({
|
|
version: 1,
|
|
links: [{ path: 'reviewer-symlink', target: '/etc/hosts' }],
|
|
})}\n`,
|
|
);
|
|
|
|
const result = await runPreflight({ root });
|
|
assert.equal(result.code, 43);
|
|
assert.match(result.message, /symbolic-link manifest/);
|
|
});
|
|
|
|
await t.test('unchanged canonical-style descendant symlinks are accepted', async () => {
|
|
const root = await fixture('symbolic-next-certified');
|
|
await installRequiredBins(root);
|
|
const link = path.join(
|
|
root,
|
|
'apps',
|
|
'web',
|
|
'.next',
|
|
'standalone',
|
|
'node_modules',
|
|
'dependency',
|
|
);
|
|
await mkdir(path.dirname(link), { recursive: true });
|
|
await symlink('../.pnpm/dependency', link);
|
|
await certifyGeneratedState(root, [
|
|
{ path: 'standalone/node_modules/dependency', target: '../.pnpm/dependency' },
|
|
]);
|
|
|
|
assert.deepEqual(await runPreflight({ root }), {
|
|
code: 0,
|
|
message: 'checkout preflight passed',
|
|
});
|
|
});
|
|
});
|
|
|
|
test('the source fingerprint includes inherited TypeScript configuration', async () => {
|
|
const root = await fixture('inherited-typescript-config');
|
|
const config = path.join(root, 'tsconfig.base.json');
|
|
await writeFile(config, '{"compilerOptions":{"strict":true}}\n');
|
|
const first = await sourceFingerprint(root);
|
|
await writeFile(config, '{"compilerOptions":{"strict":false}}\n');
|
|
const second = await sourceFingerprint(root);
|
|
|
|
assert.notEqual(first, second);
|
|
});
|
|
|
|
test('the source fingerprint rejects symbolic-link build inputs', async () => {
|
|
const root = await fixture('symbolic-source');
|
|
await writeFile(path.join(root, 'outside.ts'), 'export default 1;\n');
|
|
await symlink(path.join(root, 'outside.ts'), path.join(root, 'apps', 'web', 'src', 'linked.ts'));
|
|
|
|
await assert.rejects(sourceFingerprint(root), /must not be a symbolic link/);
|
|
});
|
|
|
|
test('the source fingerprint includes expanded public web build environment', async () => {
|
|
const root = await fixture('public-build-environment');
|
|
const envFile = path.join(root, 'apps', 'web', '.env.production');
|
|
await writeFile(
|
|
envFile,
|
|
'RM01_GATEWAY_URL=https://one.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
|
|
);
|
|
const first = await sourceFingerprint(root);
|
|
await writeFile(
|
|
envFile,
|
|
'RM01_GATEWAY_URL=https://two.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
|
|
);
|
|
const second = await sourceFingerprint(root);
|
|
|
|
assert.notEqual(first, second);
|
|
});
|
|
|
|
test('a matching generation marker accepts incremental output with mixed mtimes', async () => {
|
|
const root = await fixture('incremental-next');
|
|
await installRequiredBins(root);
|
|
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
|
await mkdir(path.dirname(generated), { recursive: true });
|
|
await writeFile(generated, 'unchanged generated output');
|
|
await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z'));
|
|
const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts');
|
|
await writeFile(fresh, 'fresh generated output');
|
|
await certifyGeneratedState(root);
|
|
|
|
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
|
});
|