需求:让非专业用户在项目操作区一次提交,运营审核通过后直接发布。 实现:由 Electron Main 完成安全打包、自动版本、幂等重试和状态脱敏;补齐友好失败反馈、唯一提交入口及隔离 Electron E2E fixture。
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { createRequire } from 'node:module';
|
|
import {
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rename,
|
|
rm,
|
|
symlink,
|
|
utimes,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
createStaticProjectPackage,
|
|
ProjectPackageError,
|
|
} from '@electron/services/project-packager';
|
|
|
|
const openRaceHook = vi.hoisted(() => ({
|
|
beforeOpen: null as null | ((path: string) => Promise<void>),
|
|
}));
|
|
|
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
|
return {
|
|
...actual,
|
|
open: async (...args: Parameters<typeof actual.open>) => {
|
|
await openRaceHook.beforeOpen?.(String(args[0]));
|
|
return await actual.open(...args);
|
|
},
|
|
};
|
|
});
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const AdmZip = require('adm-zip') as typeof import('adm-zip');
|
|
|
|
const tempDirectories: string[] = [];
|
|
|
|
async function createProject(): Promise<string> {
|
|
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-project-packager-'));
|
|
tempDirectories.push(projectPath);
|
|
await mkdir(join(projectPath, 'src'), { recursive: true });
|
|
await mkdir(join(projectPath, 'public'), { recursive: true });
|
|
await writeFile(
|
|
join(projectPath, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'space-cleaner',
|
|
private: true,
|
|
packageManager: 'npm@10.9.2',
|
|
devDependencies: { vite: '^7.0.0' },
|
|
}),
|
|
'utf8',
|
|
);
|
|
await writeFile(
|
|
join(projectPath, 'package-lock.json'),
|
|
JSON.stringify({
|
|
name: 'space-cleaner',
|
|
lockfileVersion: 3,
|
|
requires: true,
|
|
packages: {},
|
|
}),
|
|
'utf8',
|
|
);
|
|
await writeFile(join(projectPath, 'index.html'), '<div id="app"></div>', 'utf8');
|
|
await writeFile(join(projectPath, 'src', 'main.ts'), 'console.log("ready")\n', 'utf8');
|
|
await writeFile(join(projectPath, 'public', 'icon.txt'), 'icon\n', 'utf8');
|
|
return projectPath;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
openRaceHook.beforeOpen = null;
|
|
await Promise.all(
|
|
tempDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
describe('project packager', () => {
|
|
it('generates a deterministic static manifest zip and excludes local or sensitive files', async () => {
|
|
const projectPath = await createProject();
|
|
await mkdir(join(projectPath, 'node_modules', 'vite'), { recursive: true });
|
|
await mkdir(join(projectPath, '.git'), { recursive: true });
|
|
await mkdir(join(projectPath, 'dist'), { recursive: true });
|
|
await mkdir(join(projectPath, '.niancode'), { recursive: true });
|
|
for (const directory of ['.ssh', '.aws', '.gnupg', '.kube', '.docker', 'secrets']) {
|
|
await mkdir(join(projectPath, directory), { recursive: true });
|
|
await writeFile(join(projectPath, directory, 'credential'), 'private', 'utf8');
|
|
}
|
|
await writeFile(join(projectPath, 'node_modules', 'vite', 'index.js'), 'generated', 'utf8');
|
|
await writeFile(join(projectPath, '.git', 'config'), 'private', 'utf8');
|
|
await writeFile(join(projectPath, 'dist', 'index.html'), 'built', 'utf8');
|
|
await writeFile(join(projectPath, '.niancode', 'project.json'), '{}', 'utf8');
|
|
await writeFile(join(projectPath, '.env'), 'TOKEN=secret', 'utf8');
|
|
await writeFile(join(projectPath, '.env.example'), 'TOKEN=replace-me', 'utf8');
|
|
await writeFile(join(projectPath, '.npmrc'), '//registry/:_authToken=secret', 'utf8');
|
|
await writeFile(join(projectPath, 'debug.log'), 'local log', 'utf8');
|
|
await writeFile(join(projectPath, 'niancode.yml'), 'runtime: compose\n', 'utf8');
|
|
for (const filename of [
|
|
'.netrc',
|
|
'_netrc',
|
|
'.git-credentials',
|
|
'id_rsa',
|
|
'id_dsa',
|
|
'id_ecdsa',
|
|
'id_ed25519',
|
|
'service-account.json',
|
|
'service_account.prod.json',
|
|
'firebase-adminsdk-project.json',
|
|
'application_default_credentials.json',
|
|
'terraform.tfstate',
|
|
'terraform.tfstate.backup',
|
|
'production.tfvars',
|
|
'secrets.auto.tfvars.json',
|
|
'signing.ppk',
|
|
'auth-key.p8',
|
|
]) {
|
|
await writeFile(join(projectPath, filename), 'private', 'utf8');
|
|
}
|
|
|
|
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
|
|
tempDirectories.push(outputDirectory);
|
|
const firstArchive = join(outputDirectory, 'first.zip');
|
|
const secondArchive = join(outputDirectory, 'second.zip');
|
|
|
|
const first = await createStaticProjectPackage({ projectPath, archivePath: firstArchive });
|
|
await utimes(join(projectPath, 'src', 'main.ts'), new Date(), new Date());
|
|
const second = await createStaticProjectPackage({ projectPath, archivePath: secondArchive });
|
|
|
|
expect(await readFile(firstArchive)).toEqual(await readFile(secondArchive));
|
|
expect(first.sha256).toBe(second.sha256);
|
|
expect(first.archiveBytes).toBe(second.archiveBytes);
|
|
expect(first.fileCount).toBe(6);
|
|
expect(first.excludedPaths).toEqual(expect.arrayContaining([
|
|
'.env',
|
|
'.env.example',
|
|
'.git/',
|
|
'.git-credentials',
|
|
'.gnupg/',
|
|
'.niancode/',
|
|
'.npmrc',
|
|
'.ssh/',
|
|
'.aws/',
|
|
'.docker/',
|
|
'.kube/',
|
|
'.netrc',
|
|
'_netrc',
|
|
'debug.log',
|
|
'dist/',
|
|
'firebase-adminsdk-project.json',
|
|
'id_rsa',
|
|
'node_modules/',
|
|
'production.tfvars',
|
|
'secrets.auto.tfvars.json',
|
|
'secrets/',
|
|
'service-account.json',
|
|
'terraform.tfstate.backup',
|
|
]));
|
|
|
|
const archive = new AdmZip(firstArchive);
|
|
const entries = archive.getEntries();
|
|
expect(entries.map((entry) => entry.entryName)).toEqual([
|
|
'index.html',
|
|
'niancode.yml',
|
|
'package-lock.json',
|
|
'package.json',
|
|
'public/icon.txt',
|
|
'src/main.ts',
|
|
]);
|
|
expect(archive.readAsText('niancode.yml')).toBe(
|
|
'schema_version: 1\n'
|
|
+ 'kind: web\n'
|
|
+ 'runtime: static\n'
|
|
+ 'build:\n'
|
|
+ ' preset: vite\n'
|
|
+ ' package_manager: npm\n'
|
|
+ ' entry: index.html\n',
|
|
);
|
|
expect(entries.every((entry) => entry.header.time.getFullYear() === 1980)).toBe(true);
|
|
await expect(readFile(join(projectPath, 'niancode.yml'), 'utf8')).resolves.toBe('runtime: compose\n');
|
|
});
|
|
|
|
it('rejects a project that is not a locked npm Vite project', async () => {
|
|
const projectPath = await createProject();
|
|
await writeFile(
|
|
join(projectPath, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'space-cleaner',
|
|
packageManager: 'pnpm@10.0.0',
|
|
devDependencies: { vite: '^7.0.0' },
|
|
}),
|
|
'utf8',
|
|
);
|
|
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
|
|
tempDirectories.push(outputDirectory);
|
|
|
|
await expect(createStaticProjectPackage({
|
|
projectPath,
|
|
archivePath: join(outputDirectory, 'project.zip'),
|
|
})).rejects.toMatchObject<ProjectPackageError>({
|
|
code: 'PACKAGE_MANAGER_UNSUPPORTED',
|
|
message: '当前仅支持带 package-lock.json 的 npm 项目',
|
|
});
|
|
});
|
|
|
|
it('returns a precise validation error when a required root file is missing', async () => {
|
|
const projectPath = await createProject();
|
|
await rm(join(projectPath, 'index.html'));
|
|
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
|
|
tempDirectories.push(outputDirectory);
|
|
|
|
await expect(createStaticProjectPackage({
|
|
projectPath,
|
|
archivePath: join(outputDirectory, 'project.zip'),
|
|
})).rejects.toMatchObject<ProjectPackageError>({
|
|
code: 'PROJECT_FILE_MISSING',
|
|
message: '项目根目录缺少 index.html',
|
|
});
|
|
});
|
|
|
|
it('does not follow a linked directory outside the project root', async () => {
|
|
const projectPath = await createProject();
|
|
const externalPath = await mkdtemp(join(tmpdir(), 'makelore-project-external-'));
|
|
tempDirectories.push(externalPath);
|
|
await writeFile(join(externalPath, 'secret.txt'), 'must not be archived', 'utf8');
|
|
await symlink(externalPath, join(projectPath, 'linked-external'), 'junction');
|
|
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
|
|
tempDirectories.push(outputDirectory);
|
|
|
|
await expect(createStaticProjectPackage({
|
|
projectPath,
|
|
archivePath: join(outputDirectory, 'project.zip'),
|
|
})).rejects.toMatchObject<ProjectPackageError>({
|
|
code: 'PROJECT_SYMLINK_UNSUPPORTED',
|
|
});
|
|
});
|
|
|
|
it('rejects a parent directory replaced by a junction after the pre-open identity check', async () => {
|
|
const projectPath = await createProject();
|
|
const externalPath = await mkdtemp(join(tmpdir(), 'makelore-project-external-'));
|
|
tempDirectories.push(externalPath);
|
|
await writeFile(join(externalPath, 'main.ts'), 'console.log("external secret")\n', 'utf8');
|
|
const sourcePath = join(projectPath, 'src');
|
|
const sourceFilePath = join(sourcePath, 'main.ts');
|
|
let replaced = false;
|
|
openRaceHook.beforeOpen = async (path) => {
|
|
if (replaced || path !== sourceFilePath) return;
|
|
replaced = true;
|
|
await rename(sourcePath, join(projectPath, 'src-original'));
|
|
await symlink(externalPath, sourcePath, 'junction');
|
|
};
|
|
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
|
|
tempDirectories.push(outputDirectory);
|
|
|
|
await expect(createStaticProjectPackage({
|
|
projectPath,
|
|
archivePath: join(outputDirectory, 'project.zip'),
|
|
})).rejects.toMatchObject<ProjectPackageError>({
|
|
code: 'PROJECT_SYMLINK_UNSUPPORTED',
|
|
});
|
|
expect(replaced).toBe(true);
|
|
});
|
|
|
|
it('fails before allocating an oversized source package', async () => {
|
|
const projectPath = await createProject();
|
|
await writeFile(join(projectPath, 'large.bin'), Buffer.alloc(32));
|
|
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
|
|
tempDirectories.push(outputDirectory);
|
|
|
|
await expect(createStaticProjectPackage({
|
|
projectPath,
|
|
archivePath: join(outputDirectory, 'project.zip'),
|
|
limits: {
|
|
maxFiles: 20,
|
|
maxSourceBytes: 16,
|
|
maxArchiveBytes: 1024,
|
|
},
|
|
})).rejects.toMatchObject<ProjectPackageError>({ code: 'PROJECT_TOO_LARGE' });
|
|
});
|
|
|
|
it('rejects an oversized required root file before reading it into memory', async () => {
|
|
const projectPath = await createProject();
|
|
await writeFile(join(projectPath, 'package-lock.json'), Buffer.alloc(512, 0x20));
|
|
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
|
|
tempDirectories.push(outputDirectory);
|
|
|
|
await expect(createStaticProjectPackage({
|
|
projectPath,
|
|
archivePath: join(outputDirectory, 'project.zip'),
|
|
limits: {
|
|
maxFiles: 20,
|
|
maxSourceBytes: 256,
|
|
maxArchiveBytes: 1024,
|
|
},
|
|
})).rejects.toMatchObject<ProjectPackageError>({ code: 'PROJECT_TOO_LARGE' });
|
|
});
|
|
});
|