import type { SpawnSyncReturns } from 'node:child_process';
|
|
import { spawnSync } from 'node:child_process';
|
import { createHash } from 'node:crypto';
|
import {
|
chmodSync,
|
copyFileSync,
|
cpSync,
|
existsSync,
|
mkdirSync,
|
mkdtempSync,
|
readdirSync,
|
readFileSync,
|
realpathSync,
|
rmSync,
|
statSync,
|
writeFileSync,
|
} from 'node:fs';
|
import { tmpdir } from 'node:os';
|
import { dirname, join, relative, resolve } from 'node:path';
|
import process from 'node:process';
|
|
import { build } from 'vite';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
const root = resolve(__dirname, '..'); // eslint-disable-line unicorn/prefer-module
|
const temporaryRoot = mkdtempSync(join(tmpdir(), 'eln-template-filler-package-'));
|
const outputDirectory = join(temporaryRoot, 'dist');
|
const pluginPackage = join(outputDirectory, 'eln-template-filler.plugin');
|
const scriptSandbox = join(temporaryRoot, 'script-sandbox');
|
const packageScript = join(scriptSandbox, 'scripts/package.sh');
|
const dockerStubDirectory = join(temporaryRoot, 'docker-stub');
|
const dockerLog = join(temporaryRoot, 'docker.log');
|
|
interface Manifest {
|
guid: string;
|
variations: Array<{ icons: string[]; url: string }>;
|
version: string;
|
}
|
|
function listFiles(directory: string): string[] {
|
return readdirSync(directory)
|
.flatMap((name) => {
|
const absolutePath = join(directory, name);
|
return statSync(absolutePath).isDirectory() ? listFiles(absolutePath) : [relative(outputDirectory, absolutePath)];
|
})
|
.sort();
|
}
|
|
function listDirectoryFiles(directory: string, base = directory): string[] {
|
return readdirSync(directory)
|
.flatMap((name) => {
|
const absolutePath = join(directory, name);
|
return statSync(absolutePath).isDirectory() ? listDirectoryFiles(absolutePath, base) : [relative(base, absolutePath)];
|
})
|
.sort();
|
}
|
|
function readOutput(relativePath: string): string {
|
return readFileSync(join(outputDirectory, relativePath), 'utf8');
|
}
|
|
function runScript(script: string, args: string[] = [], environment: Record<string, string> = {}, cwd = root): SpawnSyncReturns<string> {
|
return spawnSync('sh', [script, ...args], {
|
cwd,
|
encoding: 'utf8',
|
env: { ...process.env, ...environment },
|
});
|
}
|
|
function runPackage(environment: Record<string, string> = {}, cwd = root): SpawnSyncReturns<string> {
|
return runScript(
|
packageScript,
|
[],
|
{
|
ONLYOFFICE_PLUGIN_DIST_DIR: outputDirectory,
|
...environment,
|
},
|
cwd,
|
);
|
}
|
|
function archiveEntries(archive: string): string[] {
|
const result = spawnSync('unzip', ['-Z1', archive], { encoding: 'utf8' });
|
expect(result.status, result.stderr).toBe(0);
|
return result.stdout.trim().split('\n').filter(Boolean);
|
}
|
|
function readArchiveEntry(archive: string, entry: string): string {
|
const result = spawnSync('unzip', ['-p', archive, entry], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
|
expect(result.status, result.stderr).toBe(0);
|
return result.stdout;
|
}
|
|
function fileHash(file: string): string {
|
return createHash('sha256').update(readFileSync(file)).digest('hex');
|
}
|
|
function writeExecutable(file: string, source: string): void {
|
writeFileSync(file, source);
|
chmodSync(file, 0o755);
|
}
|
|
function resetDockerLog(): void {
|
rmSync(dockerLog, { force: true });
|
}
|
|
function readDockerLog(): string {
|
return existsSync(dockerLog) ? readFileSync(dockerLog, 'utf8') : '';
|
}
|
|
function installEnvironment(pluginPackage: string, pluginExists = '0'): Record<string, string> {
|
return {
|
DOCKER_LOG: dockerLog,
|
DOCKER_PLUGIN_EXISTS: pluginExists,
|
ONLYOFFICE_PLUGIN_PACKAGE: pluginPackage,
|
PATH: `${dockerStubDirectory}:${process.env.PATH ?? ''}`,
|
};
|
}
|
|
function createInstallArchive(
|
name: string,
|
options: { change?: (manifest: Manifest) => void; malformedManifest?: boolean; omittedEntry?: string } = {},
|
): string {
|
const directory = mkdtempSync(join(temporaryRoot, `${name}-`));
|
const archive = join(directory, `${name}.plugin`);
|
const manifest = JSON.parse(readFileSync(join(root, 'config.json'), 'utf8')) as Manifest;
|
manifest.variations[0]!.url = 'index.html?v=0.1.0';
|
options.change?.(manifest);
|
writeFileSync(join(directory, 'config.json'), options.malformedManifest ? '{invalid json' : `${JSON.stringify(manifest)}\n`);
|
|
for (const entry of ['index.html', ...manifest.variations[0]!.icons, 'translations/langs.json']) {
|
const absolutePath = join(directory, entry);
|
mkdirSync(dirname(absolutePath), { recursive: true });
|
if (entry === options.omittedEntry) {
|
mkdirSync(absolutePath, { recursive: true });
|
writeFileSync(join(absolutePath, 'not-the-required-file'), 'directory impostor');
|
writeFileSync(`${absolutePath}.bak`, 'substring impostor');
|
} else {
|
writeFileSync(absolutePath, entry === 'translations/langs.json' ? '[]\n' : `fixture for ${entry}`);
|
}
|
}
|
|
const entries = readdirSync(directory);
|
const zipped = spawnSync('zip', ['-q', '-r', archive, ...entries], { cwd: directory, encoding: 'utf8' });
|
expect(zipped.status, zipped.stderr).toBe(0);
|
return archive;
|
}
|
|
function createManifestArchive(name: string, change: (manifest: Manifest) => void): string {
|
return createInstallArchive(name, { change });
|
}
|
|
beforeAll(async () => {
|
await build({
|
root,
|
configFile: join(root, 'vite.config.ts'),
|
build: {
|
emptyOutDir: true,
|
outDir: outputDirectory,
|
},
|
});
|
|
mkdirSync(join(scriptSandbox, 'scripts'), { recursive: true });
|
copyFileSync(join(root, 'scripts/package.sh'), packageScript);
|
copyFileSync(join(root, 'scripts/production-contract.mjs'), join(scriptSandbox, 'scripts/production-contract.mjs'));
|
chmodSync(packageScript, 0o755);
|
|
mkdirSync(dockerStubDirectory, { recursive: true });
|
writeExecutable(
|
join(dockerStubDirectory, 'docker'),
|
`#!/usr/bin/env sh
|
set -eu
|
{
|
printf '['
|
for argument in "$@"; do
|
printf '<%s>' "$argument"
|
done
|
printf ']\\n'
|
} >> "$DOCKER_LOG"
|
case "$*" in
|
*' test -f '*) exit "\${DOCKER_PLUGIN_EXISTS:-0}" ;;
|
esac
|
if [ "\${1:-}" = 'cp' ]; then
|
exit "\${DOCKER_CP_STATUS:-0}"
|
fi
|
exit 0
|
`,
|
);
|
}, 30_000);
|
|
afterAll(() => {
|
rmSync(temporaryRoot, { force: true, recursive: true });
|
});
|
|
describe('m1 正式构建产物', () => {
|
it('保留正式身份、版本和入口', () => {
|
const manifest = JSON.parse(readOutput('config.json')) as {
|
guid: string;
|
minVersion: string;
|
name: string;
|
nameLocale: Record<string, string>;
|
variations: Array<{ isInsideMode: boolean; isModal: boolean; size?: number[]; url: string }>;
|
version: string;
|
};
|
|
expect(manifest).toMatchObject({
|
guid: 'asc.{7F4E8F35-5D66-47F8-A5B4-6AB3FAACF565}',
|
minVersion: '9.4.0',
|
name: 'ELN 实验模板填写',
|
nameLocale: { 'zh-ZH': 'ELN 实验模板填写' },
|
version: '0.1.0',
|
});
|
expect(manifest.variations[0]).toMatchObject({
|
isInsideMode: true,
|
isModal: false,
|
url: 'index.html?v=0.1.0',
|
});
|
expect(manifest.variations[0]).not.toHaveProperty('size');
|
});
|
|
it('只输出正式入口、manifest、哈希资源和固定路径图标', () => {
|
const files = listFiles(outputDirectory);
|
|
expect(files).toContain('config.json');
|
expect(files).toContain('index.html');
|
expect(files).not.toContain('vue-index.html');
|
expect(files).toContain('resources/icon.png');
|
expect(files).toContain('resources/icon@2x.png');
|
expect(files).toContain('translations/langs.json');
|
expect(files).not.toContain('resources/icon-v2.png');
|
expect(files).not.toContain('resources/icon-v2@2x.png');
|
expect(
|
files.some((file) =>
|
/^assets\/.(?:[^-\n\r\u2028\u2029]*-[^-\w\n\r\u2028\u2029])*[^-\n\r\u2028\u2029]*-[-\w]+(?:[^-\w\n\r\u2028\u2029](?:[^-\n\r\u2028\u2029]*-[^-\w\n\r\u2028\u2029])*[^-\n\r\u2028\u2029]*-[-\w]+)*\.js$/.test(
|
file,
|
),
|
),
|
).toBe(true);
|
expect(
|
files.some((file) =>
|
/^assets\/.(?:[^-\n\r\u2028\u2029]*-[^-\w\n\r\u2028\u2029])*[^-\n\r\u2028\u2029]*-[-\w]+(?:[^-\w\n\r\u2028\u2029](?:[^-\n\r\u2028\u2029]*-[^-\w\n\r\u2028\u2029])*[^-\n\r\u2028\u2029]*-[-\w]+)*\.css$/.test(
|
file,
|
),
|
),
|
).toBe(true);
|
expect(files.some((file) => file.endsWith('.map'))).toBe(false);
|
|
for (const legacyFile of ['plugin.js', 'field-types.js', 'office-commands.js']) {
|
expect(files).not.toContain(legacyFile);
|
}
|
});
|
|
it('使用相对哈希资源并保留相对 ONLYOFFICE SDK 地址', () => {
|
const html = readOutput('index.html');
|
|
expect(html).toMatch(/(?:src|href)="\.\/assets\//);
|
expect(html).not.toMatch(/(?:src|href)="\/assets\//);
|
expect(html).toContain('href="../v1/plugins.css"');
|
expect(html).toContain('src="../v1/plugins.js"');
|
});
|
|
it('bundle 保留两个自包含 command 的产物级调用标识', () => {
|
const javascript = listFiles(outputDirectory)
|
.filter((file) => file.endsWith('.js'))
|
.map((file) => readOutput(file))
|
.join('\n');
|
|
expect(javascript).toContain('callCommand');
|
expect(javascript).toContain('elnTemplateFillInspect');
|
expect(javascript).toContain('elnTemplateFillChanges');
|
});
|
|
it('按高置信规则审计临时构建与最终 zip,且只放行 Ant Design 内部标识', () => {
|
const packaged = runPackage();
|
expect(packaged.status, packaged.stderr).toBe(0);
|
const outputFiles = listFiles(outputDirectory);
|
const zipFiles = archiveEntries(pluginPackage);
|
const textFiles = outputFiles.filter((file) => /\.(?:css|html|js|json)$/.test(file));
|
const buildText = textFiles.map((file) => readOutput(file)).join('\n');
|
const zipTextFiles = zipFiles.filter((file) => /\.(?:css|html|js|json)$/.test(file));
|
const zipText = zipTextFiles.map((file) => readArchiveEntry(pluginPackage, file)).join('\n');
|
|
expect(zipTextFiles.sort()).toEqual(textFiles.sort());
|
for (const textOutput of [buildText, zipText]) {
|
const antDLocalhostContexts = textOutput.match(
|
/[A-Za-z_$][\w$]*="\(\?:"\+[A-Za-z_$][\w$]*\+"\|www\\\\\.\)"\+[A-Za-z_$][\w$]*\+"\(\?:localhost\|"\+[A-Za-z_$][\w$]*\+"\|"\+[A-Za-z_$][\w$]*\+"\|"\+[A-Za-z_$][\w$]*\+[A-Za-z_$][\w$]*\+[A-Za-z_$][\w$]*\+"\)"\+[A-Za-z_$][\w$]*\+[A-Za-z_$][\w$]*/g,
|
);
|
const antDSecret = 'SECRET_COMBOBOX_MODE_DO_NOT_USE';
|
const antDSecretContexts = [
|
new RegExp(`[A-Za-z_$][\\w$]*="${antDSecret}",[A-Za-z_$][\\w$]*=[A-Za-z_$][\\w$]*\\(`, 'g'),
|
new RegExp(`,${antDSecret}:[A-Za-z_$][\\w$]*,slots:Object`, 'g'),
|
new RegExp(`\\.${antDSecret},getInputElement:`, 'g'),
|
].flatMap((pattern) => textOutput.match(pattern) ?? []);
|
expect(antDLocalhostContexts).toHaveLength(1);
|
expect(antDSecretContexts).toHaveLength(3);
|
expect(textOutput.split(antDSecret)).toHaveLength(4);
|
const auditedText = [antDLocalhostContexts![0]!, ...antDSecretContexts].reduce((text, context) => text.replace(context, ''), textOutput);
|
expect(auditedText).not.toMatch(/\b[A-Z0-9_]*SECRET[A-Z0-9_]*\b/);
|
expect(auditedText).not.toMatch(/ONLYOFFICE_JWT_SECRET|-----BEGIN [A-Z ]*PRIVATE KEY-----/);
|
expect(auditedText).not.toMatch(
|
/\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b|https?:\/\/(?:10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(?:1[6-9]|2\d|3[01])\.\d+\.\d+)/i,
|
);
|
expect(auditedText).not.toMatch(/客户数据样例|客户样例|customer[-_ ]?(?:data|sample)/i);
|
}
|
expect([...outputFiles, ...zipFiles]).not.toEqual(expect.arrayContaining([expect.stringMatching(/\.(?:docx?|pdf)$/i)]));
|
expect([...outputFiles, ...zipFiles]).not.toEqual(expect.arrayContaining([expect.stringMatching(/(?:截图|screenshot).*\.(?:png|jpe?g)$/i)]));
|
expect([...outputFiles, ...zipFiles]).not.toEqual(
|
expect.arrayContaining([expect.stringMatching(/^(?:deploy\/nginx|vite\/onlyoffice-plugin\.ts|scripts\/release\.sh)/)]),
|
);
|
});
|
|
it.each(['ONLYOFFICE_PLUGIN_DEV_PORT', 'ONLYOFFICE_DOCS_URL'])('拒绝开发环境变量标识 %s 进入正式产物', (marker) => {
|
const sandbox = mkdtempSync(join(temporaryRoot, 'development-marker-'));
|
cpSync(outputDirectory, sandbox, { recursive: true });
|
const javascript = listDirectoryFiles(sandbox).find((file) => file.endsWith('.js'));
|
expect(javascript).toBeDefined();
|
const asset = join(sandbox, javascript!);
|
writeFileSync(asset, `${readFileSync(asset, 'utf8')}\n${marker}\n`);
|
|
const validated = spawnSync(process.execPath, [join(root, 'scripts/production-contract.mjs'), 'validate-dist', sandbox], {
|
encoding: 'utf8',
|
});
|
|
expect(validated.status).not.toBe(0);
|
});
|
|
it('不写回正式源码 manifest', () => {
|
const sourceManifest = JSON.parse(readFileSync(join(root, 'config.json'), 'utf8')) as {
|
variations: Array<{ url: string }>;
|
version: string;
|
};
|
|
expect(sourceManifest.version).toBe('0.1.0');
|
expect(sourceManifest.variations[0]?.url).toBe('index.html?v=0.1.0');
|
});
|
});
|
|
describe('正式打包与安装脚本', () => {
|
it('覆盖替换后的目标目录文件集合严格等于安装包清单', () => {
|
const packaged = runPackage();
|
expect(packaged.status, packaged.stderr).toBe(0);
|
const targetDirectory = join(temporaryRoot, 'installed-plugin');
|
const installDirectory = join(temporaryRoot, '.installed-plugin.install');
|
const backupDirectory = join(temporaryRoot, '.installed-plugin.backup');
|
mkdirSync(join(targetDirectory, 'assets'), { recursive: true });
|
writeFileSync(join(targetDirectory, 'vue-index.html'), 'stale candidate');
|
writeFileSync(join(targetDirectory, 'assets/old-hash.js'), 'stale hash');
|
|
const replaced = runScript(join(root, 'scripts/replace-plugin-directory.sh'), [pluginPackage, targetDirectory, installDirectory, backupDirectory]);
|
|
expect(replaced.status, replaced.stderr).toBe(0);
|
expect(listDirectoryFiles(targetDirectory)).toEqual(
|
archiveEntries(pluginPackage)
|
.filter((entry) => !entry.endsWith('/'))
|
.sort(),
|
);
|
expect(existsSync(installDirectory)).toBe(false);
|
expect(existsSync(backupDirectory)).toBe(false);
|
});
|
|
it('staging 解压失败时保留原目录、清理临时目录并返回原退出码', () => {
|
const targetDirectory = join(temporaryRoot, 'failed-installed-plugin');
|
const installDirectory = join(temporaryRoot, '.failed-installed-plugin.install');
|
const backupDirectory = join(temporaryRoot, '.failed-installed-plugin.backup');
|
const unzipStubDirectory = join(temporaryRoot, 'failing-unzip');
|
const archive = join(temporaryRoot, 'unused.plugin');
|
mkdirSync(targetDirectory, { recursive: true });
|
mkdirSync(unzipStubDirectory, { recursive: true });
|
writeFileSync(join(targetDirectory, 'keep.txt'), 'original');
|
writeFileSync(archive, 'stub archive');
|
writeExecutable(join(unzipStubDirectory, 'unzip'), '#!/usr/bin/env sh\nexit 37\n');
|
|
const failed = runScript(join(root, 'scripts/replace-plugin-directory.sh'), [archive, targetDirectory, installDirectory, backupDirectory], {
|
PATH: `${unzipStubDirectory}:${process.env.PATH ?? ''}`,
|
});
|
|
expect(failed.status).toBe(37);
|
expect(readFileSync(join(targetDirectory, 'keep.txt'), 'utf8')).toBe('original');
|
expect(existsSync(installDirectory)).toBe(false);
|
expect(existsSync(backupDirectory)).toBe(false);
|
});
|
|
it('在覆盖 dist 上真实生成 CRC 有效且不自包含的正式包', () => {
|
const packaged = runPackage();
|
expect(packaged.status, packaged.stderr).toBe(0);
|
expect(packaged.stdout.trim()).toBe(pluginPackage);
|
|
const integrity = spawnSync('unzip', ['-tq', pluginPackage], { encoding: 'utf8' });
|
expect(integrity.status, integrity.stderr).toBe(0);
|
expect(archiveEntries(pluginPackage).sort()).toEqual(
|
[
|
'assets/',
|
...listFiles(outputDirectory).filter((file) => file.startsWith('assets/')),
|
'config.json',
|
'resources/',
|
'resources/icon.png',
|
'resources/icon@2x.png',
|
'translations/',
|
'translations/langs.json',
|
'index.html',
|
].sort(),
|
);
|
expect(archiveEntries(pluginPackage)).not.toContain('eln-template-filler.plugin');
|
});
|
|
it('支持相对的 ONLYOFFICE_PLUGIN_DIST_DIR', () => {
|
const packaged = runPackage({ ONLYOFFICE_PLUGIN_DIST_DIR: 'dist' }, temporaryRoot);
|
|
expect(packaged.status, packaged.stderr).toBe(0);
|
expect(realpathSync(packaged.stdout.trim())).toBe(realpathSync(pluginPackage));
|
});
|
|
it.each([
|
['顶层额外文件', 'debug.txt'],
|
['assets 隐藏文件', 'assets/.hidden.js'],
|
['assets 子目录', 'assets/nested/extra.js'],
|
['resources 额外文件', 'resources/extra.png'],
|
])('拒绝外部 dist 中的%s', (_, extraPath) => {
|
const externalDist = mkdtempSync(join(temporaryRoot, 'external-dist-'));
|
cpSync(outputDirectory, externalDist, { recursive: true });
|
rmSync(join(externalDist, 'eln-template-filler.plugin'), { force: true });
|
const extra = join(externalDist, extraPath);
|
mkdirSync(dirname(extra), { recursive: true });
|
writeFileSync(extra, 'unexpected');
|
|
const packaged = runPackage({ ONLYOFFICE_PLUGIN_DIST_DIR: externalDist });
|
|
expect(packaged.status).not.toBe(0);
|
expect(existsSync(join(externalDist, 'eln-template-filler.plugin'))).toBe(false);
|
});
|
|
it('zip 失败时保留旧包哈希并清理唯一临时文件', () => {
|
const seeded = runPackage();
|
expect(seeded.status, seeded.stderr).toBe(0);
|
const previousHash = fileHash(pluginPackage);
|
const zipStubDirectory = join(temporaryRoot, 'failing-zip');
|
const zipLog = join(temporaryRoot, 'zip.log');
|
mkdirSync(zipStubDirectory, { recursive: true });
|
writeExecutable(
|
join(zipStubDirectory, 'zip'),
|
`#!/usr/bin/env sh
|
printf '%s\\n' "$*" > "$ZIP_STUB_LOG"
|
exit 29
|
`,
|
);
|
|
const failed = runPackage({
|
PATH: `${zipStubDirectory}:${process.env.PATH ?? ''}`,
|
ZIP_STUB_LOG: zipLog,
|
});
|
|
expect(failed.status).not.toBe(0);
|
expect(readFileSync(zipLog, 'utf8')).toMatch(/\.eln-template-filler\.\d+\.plugin/);
|
expect(fileHash(pluginPackage)).toBe(previousHash);
|
expect(readdirSync(outputDirectory).filter((file) => /^\.eln-template-filler\..+\.plugin$/.test(file))).toEqual([]);
|
});
|
|
it('从 dist 显式打包并以校验后的临时包原子替换', () => {
|
const scriptPath = join(root, 'scripts/package.sh');
|
expect(existsSync(scriptPath)).toBe(true);
|
const script = readFileSync(scriptPath, 'utf8');
|
|
expect(script).toMatch(/^#!\/usr\/bin\/env sh\nset -eu/);
|
expect(script).toContain('jq -e');
|
expect(script).toContain('"$DIST_DIR/config.json"');
|
expect(script).toContain('index.html');
|
expect(script).not.toContain('vue-index.html');
|
expect(script).toContain('assets');
|
expect(script).toContain('resources/icon.png');
|
expect(script).toContain('resources/icon@2x.png');
|
expect(script).toContain('translations/langs.json');
|
expect(script).not.toContain('rm -f "$PACKAGE"');
|
expect(script).toMatch(/cd "\$DIST_DIR"/);
|
expect(script).toContain('production-contract.mjs');
|
expect(script).toContain('validate-dist');
|
expect(script).not.toMatch(/\bfind\b|\bgrep\b/);
|
expect(script).toContain('ONLYOFFICE_PLUGIN_DIST_DIR');
|
expect(script).toContain('unzip -tq "$TEMP_PACKAGE"');
|
expect(script).toContain('mv -f "$TEMP_PACKAGE" "$PACKAGE"');
|
expect(script).not.toContain('zip -q -r "$PACKAGE" .');
|
expect(script).not.toContain('zip -q -r');
|
});
|
|
it('仅在显式提供绝对包路径时绕过正式 package.sh', () => {
|
const script = readFileSync(join(root, 'scripts/install-docker.sh'), 'utf8');
|
|
expect(script).toContain('ONLYOFFICE_PLUGIN_PACKAGE');
|
expect(script).toContain('replace-plugin-directory.sh');
|
expect(script).toMatch(/case "\$ONLYOFFICE_PLUGIN_PACKAGE" in/);
|
expect(script).toMatch(/\/\*\)/);
|
expect(script).toMatch(/\[ ! -f "\$ONLYOFFICE_PLUGIN_PACKAGE" \]/);
|
expect(script).toContain('PACKAGE=$ONLYOFFICE_PLUGIN_PACKAGE');
|
expect(script).toContain('PACKAGE=$("$SCRIPT_DIR/package.sh")');
|
expect(script).not.toContain('eval ');
|
expect(script).toContain('docker cp "$PACKAGE" "$CONTAINER:$CONTAINER_PACKAGE"');
|
});
|
|
it.each([
|
['覆盖', '0', '<sh></tmp/replace-plugin-directory.', 'pluginsmanager>'],
|
['首次', '1', 'pluginsmanager>', '<sh></tmp/replace-plugin-directory.'],
|
])('正式包带空格路径通过预检后走%s安装并在退出时清理唯一临时包', (_label, pluginExists, expected, unexpected) => {
|
const packaged = runPackage();
|
expect(packaged.status, packaged.stderr).toBe(0);
|
const spacedDirectory = join(temporaryRoot, 'plugin packages');
|
const spacedPackage = join(spacedDirectory, 'formal plugin.plugin');
|
mkdirSync(spacedDirectory, { recursive: true });
|
copyFileSync(pluginPackage, spacedPackage);
|
resetDockerLog();
|
|
const installed = runScript(join(root, 'scripts/install-docker.sh'), ['test onlyoffice'], installEnvironment(spacedPackage, pluginExists));
|
const log = readDockerLog();
|
|
expect(installed.status, installed.stderr).toBe(0);
|
expect(log).toContain(`<cp><${spacedPackage}><test onlyoffice:/tmp/eln-template-filler.plugin.`);
|
expect(log).toContain(expected);
|
expect(log).not.toContain(unexpected);
|
expect(log).toMatch(/<find><\/var\/www\/onlyoffice\/documentserver\/sdkjs-plugins\/\{7F4E8F35-5D66-47F8-A5B4-6AB3FAACF565\}>.*<\*\.gz><-delete>/);
|
expect(log).toMatch(/<exec><test onlyoffice><rm><-f><\/tmp\/eln-template-filler\.plugin\.\d+>/);
|
});
|
|
it('manifest 入口去除 query 后精确匹配包内 HTML', () => {
|
const pluginPackage = createInstallArchive('query-url', {
|
change: (manifest) => {
|
manifest.variations[0]!.url = 'index.html?cache=abc&v=0.1.0';
|
},
|
});
|
resetDockerLog();
|
|
const installed = runScript(join(root, 'scripts/install-docker.sh'), [], installEnvironment(pluginPackage));
|
|
expect(installed.status, installed.stderr).toBe(0);
|
expect(readDockerLog()).toContain('<cp>');
|
});
|
|
it.each(['index.html', 'resources/icon.png', 'resources/icon@2x.png', 'translations/langs.json'])(
|
'%s 缺失时同名目录和 substring 条目不能通过预检',
|
(omittedEntry) => {
|
const pluginPackage = createInstallArchive(`missing-${omittedEntry.replaceAll('/', '-')}`, { omittedEntry });
|
resetDockerLog();
|
|
const installed = runScript(join(root, 'scripts/install-docker.sh'), [], installEnvironment(pluginPackage));
|
|
expect(installed.status).not.toBe(0);
|
expect(readDockerLog()).toBe('');
|
},
|
);
|
|
it('cRC 正常但 config.json 无法由 jq 解析时在 Docker 前拒绝', () => {
|
const pluginPackage = createInstallArchive('malformed-manifest', { malformedManifest: true });
|
resetDockerLog();
|
|
const installed = runScript(join(root, 'scripts/install-docker.sh'), [], installEnvironment(pluginPackage));
|
|
expect(installed.status).not.toBe(0);
|
expect(readDockerLog()).toBe('');
|
});
|
|
it('docker cp 失败仍清理唯一容器路径并保留原退出码', () => {
|
const packaged = runPackage();
|
expect(packaged.status, packaged.stderr).toBe(0);
|
resetDockerLog();
|
|
const installed = runScript(join(root, 'scripts/install-docker.sh'), [], {
|
...installEnvironment(pluginPackage),
|
DOCKER_CP_STATUS: '37',
|
});
|
const log = readDockerLog();
|
|
expect(installed.status).toBe(37);
|
expect(log).toContain(`<cp><${pluginPackage}>`);
|
expect(log).toMatch(/<exec><cx-onlyoffice><rm><-f><\/tmp\/eln-template-filler\.plugin\.\d+>/);
|
expect(log).not.toContain('pluginsmanager>');
|
expect(log).not.toContain('<unzip><-q><-o>');
|
});
|
|
it.each([
|
[
|
'损坏 zip',
|
() => {
|
const file = join(temporaryRoot, 'invalid plugin.plugin');
|
writeFileSync(file, 'not a zip');
|
return file;
|
},
|
],
|
[
|
'错误 GUID',
|
() =>
|
createManifestArchive('wrong-guid', (manifest) => {
|
manifest.guid = 'asc.{00000000-0000-0000-0000-000000000000}';
|
}),
|
],
|
[
|
'错误入口',
|
() =>
|
createManifestArchive('wrong-url', (manifest) => {
|
manifest.variations[0]!.url = 'unexpected.html?v=0.1.0';
|
}),
|
],
|
] as const)('%s 在任何 Docker 调用前被拒绝', (_label, createPackage) => {
|
resetDockerLog();
|
const installed = runScript(join(root, 'scripts/install-docker.sh'), [], installEnvironment(createPackage()));
|
|
expect(installed.status).not.toBe(0);
|
expect(readDockerLog()).toBe('');
|
});
|
|
it('正式 package.sh 返回的损坏包同样在 Docker 前被拒绝', () => {
|
const invalidPackage = join(temporaryRoot, 'formal-invalid.plugin');
|
writeFileSync(invalidPackage, 'not a zip');
|
const formalSandbox = join(temporaryRoot, 'formal-install', 'scripts');
|
mkdirSync(formalSandbox, { recursive: true });
|
copyFileSync(join(root, 'scripts/install-docker.sh'), join(formalSandbox, 'install-docker.sh'));
|
writeExecutable(
|
join(formalSandbox, 'package.sh'),
|
`#!/usr/bin/env sh
|
printf '%s\\n' '${invalidPackage}'
|
`,
|
);
|
resetDockerLog();
|
|
const installed = runScript(join(formalSandbox, 'install-docker.sh'), [], {
|
DOCKER_LOG: dockerLog,
|
DOCKER_PLUGIN_EXISTS: '0',
|
PATH: `${dockerStubDirectory}:${process.env.PATH ?? ''}`,
|
});
|
|
expect(installed.status).not.toBe(0);
|
expect(readDockerLog()).toBe('');
|
});
|
});
|