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,
|
rmSync,
|
statSync,
|
symlinkSync,
|
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 sourceReleaseScript = join(root, 'scripts/release.sh');
|
const sourceProductionContract = join(root, 'scripts/production-contract.mjs');
|
const nginxExample = join(root, 'deploy/nginx/eln-template-annotator.conf.example');
|
const temporaryRoot = mkdtempSync(join(tmpdir(), 'eln-template-annotator-release-'));
|
const freshDist = join(temporaryRoot, 'fresh-dist');
|
|
interface Manifest {
|
guid: string;
|
variations: Array<{ url: string }>;
|
version: string;
|
}
|
|
interface ReleaseSandbox {
|
dist: string;
|
pluginRoot: string;
|
releaseRoot: string;
|
script: string;
|
target: string;
|
}
|
|
function listFiles(directory: string, base = directory): string[] {
|
return readdirSync(directory)
|
.flatMap((name) => {
|
const absolutePath = join(directory, name);
|
return statSync(absolutePath).isDirectory() ? listFiles(absolutePath, base) : [relative(base, absolutePath)];
|
})
|
.sort();
|
}
|
|
function fileHash(file: string): string {
|
return createHash('sha256').update(readFileSync(file)).digest('hex');
|
}
|
|
function createSandbox(name: string): ReleaseSandbox {
|
const pluginRoot = mkdtempSync(join(temporaryRoot, `${name}-`));
|
const dist = join(pluginRoot, 'dist');
|
const script = join(pluginRoot, 'scripts/release.sh');
|
const releaseRoot = join(pluginRoot, 'releases');
|
|
cpSync(freshDist, dist, { recursive: true });
|
mkdirSync(dirname(script), { recursive: true });
|
copyFileSync(sourceReleaseScript, script);
|
copyFileSync(sourceProductionContract, join(dirname(script), 'production-contract.mjs'));
|
chmodSync(script, 0o755);
|
|
return {
|
dist,
|
pluginRoot,
|
releaseRoot,
|
script,
|
target: join(releaseRoot, 'onlyoffice-plugins/0.1.0/eln-template-annotator'),
|
};
|
}
|
|
function runRelease(
|
sandbox: ReleaseSandbox,
|
releaseRoot = sandbox.releaseRoot,
|
cwd = sandbox.pluginRoot,
|
environment: Record<string, string> = {},
|
): SpawnSyncReturns<string> {
|
return spawnSync('sh', [sandbox.script], {
|
cwd,
|
encoding: 'utf8',
|
env: { ...process.env, ONLYOFFICE_RELEASE_ROOT: releaseRoot, ...environment },
|
});
|
}
|
|
function stagingDirectories(sandbox: ReleaseSandbox): string[] {
|
const parent = dirname(sandbox.target);
|
return existsSync(parent) ? readdirSync(parent).filter((name) => name.startsWith('.eln-template-annotator.staging.')) : [];
|
}
|
|
function updateManifest(sandbox: ReleaseSandbox, change: (manifest: Manifest) => void): void {
|
const manifestPath = join(sandbox.dist, 'config.json');
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest;
|
change(manifest);
|
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`);
|
}
|
|
function productionAsset(sandbox: ReleaseSandbox, extension: 'css' | 'js'): string {
|
return readdirSync(join(sandbox.dist, 'assets')).find((name) => name.endsWith(`.${extension}`))!;
|
}
|
|
beforeAll(async () => {
|
await build({
|
root,
|
configFile: join(root, 'vite.config.ts'),
|
build: { emptyOutDir: true, outDir: freshDist },
|
});
|
}, 30_000);
|
|
afterAll(() => {
|
rmSync(temporaryRoot, { force: true, recursive: true });
|
});
|
|
describe('immutable release script', () => {
|
it('从 fresh dist 生成严格 7 文件正式 release', () => {
|
const sandbox = createSandbox('fresh');
|
writeFileSync(join(sandbox.dist, 'eln-template-annotator.plugin'), 'must not be released');
|
|
const result = runRelease(sandbox);
|
|
expect(result.status, result.stderr).toBe(0);
|
expect(listFiles(sandbox.target)).toEqual(
|
[
|
`assets/${productionAsset(sandbox, 'css')}`,
|
`assets/${productionAsset(sandbox, 'js')}`,
|
'config.json',
|
'index.html',
|
'resources/icon.png',
|
'resources/icon@2x.png',
|
'translations/langs.json',
|
].sort(),
|
);
|
});
|
|
it('将最终目录设为 0755、文件设为 0644', () => {
|
const sandbox = createSandbox('permissions');
|
expect(runRelease(sandbox).status).toBe(0);
|
|
const pending = [sandbox.target];
|
while (pending.length > 0) {
|
const directory = pending.pop()!;
|
expect(statSync(directory).mode & 0o777).toBe(0o755);
|
for (const name of readdirSync(directory)) {
|
const entry = join(directory, name);
|
if (statSync(entry).isDirectory()) pending.push(entry);
|
else expect(statSync(entry).mode & 0o777).toBe(0o644);
|
}
|
}
|
});
|
|
it('相同内容重复发布时幂等成功', () => {
|
const sandbox = createSandbox('idempotent');
|
expect(runRelease(sandbox).status).toBe(0);
|
const before = listFiles(sandbox.target).map((file) => [file, fileHash(join(sandbox.target, file))]);
|
|
const second = runRelease(sandbox);
|
|
expect(second.status, second.stderr).toBe(0);
|
expect(listFiles(sandbox.target).map((file) => [file, fileHash(join(sandbox.target, file))])).toEqual(before);
|
expect(stagingDirectories(sandbox)).toEqual([]);
|
});
|
|
it('已有不同内容时拒绝覆盖并保持旧 release', () => {
|
const sandbox = createSandbox('immutable');
|
mkdirSync(sandbox.target, { recursive: true });
|
writeFileSync(join(sandbox.target, 'keep.txt'), 'old release');
|
|
const result = runRelease(sandbox);
|
|
expect(result.status).not.toBe(0);
|
expect(readFileSync(join(sandbox.target, 'keep.txt'), 'utf8')).toBe('old release');
|
expect(stagingDirectories(sandbox)).toEqual([]);
|
});
|
|
it('相对 release root 按调用目录解析', () => {
|
const sandbox = createSandbox('relative');
|
const cwd = mkdtempSync(join(temporaryRoot, 'relative-cwd-'));
|
const result = runRelease(sandbox, 'published', cwd);
|
|
expect(result.status, result.stderr).toBe(0);
|
expect(listFiles(join(cwd, 'published/onlyoffice-plugins/0.1.0/eln-template-annotator'))).toHaveLength(7);
|
});
|
|
it.each([
|
['文件系统根目录', (_sandbox: ReleaseSandbox) => '/'],
|
['插件目录', (sandbox: ReleaseSandbox) => sandbox.pluginRoot],
|
['dist 内目录', (sandbox: ReleaseSandbox) => join(sandbox.dist, 'nested/releases')],
|
])('拒绝不安全的%s且不生成 target', (_, releaseRoot) => {
|
const sandbox = createSandbox('unsafe-root');
|
const result = runRelease(sandbox, releaseRoot(sandbox));
|
|
expect(result.status).not.toBe(0);
|
expect(existsSync(sandbox.target)).toBe(false);
|
});
|
|
it('缺少 dist 时失败', () => {
|
const sandbox = createSandbox('missing-dist');
|
rmSync(sandbox.dist, { recursive: true });
|
|
expect(runRelease(sandbox).status).not.toBe(0);
|
expect(existsSync(sandbox.target)).toBe(false);
|
});
|
|
it.each([
|
['错误版本', (sandbox: ReleaseSandbox) => updateManifest(sandbox, (manifest) => (manifest.version = '0.1.1'))],
|
['错误 GUID', (sandbox: ReleaseSandbox) => updateManifest(sandbox, (manifest) => (manifest.guid = 'asc.{WRONG}'))],
|
['错误正式 URL', (sandbox: ReleaseSandbox) => updateManifest(sandbox, (manifest) => (manifest.variations[0]!.url = 'index.html?v=dev'))],
|
])('%s时拒绝发布', (_, corrupt) => {
|
const sandbox = createSandbox('invalid-manifest');
|
corrupt(sandbox);
|
|
expect(runRelease(sandbox).status).not.toBe(0);
|
expect(existsSync(sandbox.target)).toBe(false);
|
});
|
|
it('拒绝额外的哈希资产,保持严格 7 文件', () => {
|
const sandbox = createSandbox('extra-asset');
|
const html = join(sandbox.dist, 'index.html');
|
writeFileSync(join(sandbox.dist, 'assets/index-Extra.js'), 'console.log("extra")');
|
writeFileSync(html, readFileSync(html, 'utf8').replace('</body>', '<script src="./assets/index-Extra.js"></script></body>'));
|
|
expect(runRelease(sandbox).status).not.toBe(0);
|
expect(existsSync(sandbox.target)).toBe(false);
|
});
|
|
it('拒绝开发或敏感内容', () => {
|
const sandbox = createSandbox('unsafe-content');
|
const javascript = join(sandbox.dist, 'assets', productionAsset(sandbox, 'js'));
|
writeFileSync(javascript, `${readFileSync(javascript, 'utf8')}\nconst endpoint = "ws://localhost:4173";`);
|
|
expect(runRelease(sandbox).status).not.toBe(0);
|
expect(existsSync(sandbox.target)).toBe(false);
|
});
|
|
it('发布当前目录时保持 previous-test 旧 release 不变', () => {
|
const sandbox = createSandbox('previous');
|
const oldRelease = join(sandbox.releaseRoot, 'onlyoffice-plugins/previous-test/eln-template-annotator');
|
mkdirSync(oldRelease, { recursive: true });
|
writeFileSync(join(oldRelease, 'keep.txt'), 'previous release');
|
|
expect(runRelease(sandbox).status).toBe(0);
|
expect(readFileSync(join(oldRelease, 'keep.txt'), 'utf8')).toBe('previous release');
|
});
|
|
it('拒绝 release 路径中的 symlink,且不写入外部目录', () => {
|
const sandbox = createSandbox('symlink');
|
const external = mkdtempSync(join(temporaryRoot, 'external-release-'));
|
mkdirSync(sandbox.releaseRoot);
|
symlinkSync(external, join(sandbox.releaseRoot, 'onlyoffice-plugins'));
|
|
expect(runRelease(sandbox).status).not.toBe(0);
|
expect(readdirSync(external)).toEqual([]);
|
});
|
});
|
|
describe('nginx release cache contract', () => {
|
it('使用显式 release root 并禁止 MIME 嗅探', () => {
|
const source = readFileSync(nginxExample, 'utf8');
|
expect(source).toContain('root <ONLYOFFICE_RELEASE_ROOT>;');
|
expect(source.match(/add_header X-Content-Type-Options "nosniff" always;/g)?.length).toBeGreaterThanOrEqual(3);
|
});
|
|
it('manifest 和 HTML 每次重新验证', () => {
|
const source = readFileSync(nginxExample, 'utf8');
|
expect(source).toContain(String.raw`location ~ ^/onlyoffice-plugins/[^/]+/eln-template-annotator/(?:config\.json|index\.html)$ {`);
|
expect(source).toContain('add_header Cache-Control "no-cache, must-revalidate" always;');
|
expect(source).toContain('try_files $uri =404;');
|
});
|
|
it('仅对 Vite 哈希 JS/CSS 使用 immutable 缓存', () => {
|
const source = readFileSync(nginxExample, 'utf8');
|
expect(source).toContain(String.raw`assets/.+-[A-Za-z0-9_-]+\.(?:js|css)$`);
|
expect(source).toContain('add_header Cache-Control "public, max-age=31536000, immutable" always;');
|
});
|
|
it('图标有限缓存且不包含开发代理或真实主机', () => {
|
const source = readFileSync(nginxExample, 'utf8');
|
expect(source).toContain(String.raw`resources/icon(?:@2x)?\.png$`);
|
expect(source).not.toMatch(/proxy_pass|Access-Control-Allow-Origin|secret|token|jwt|localhost|127\.0\.0\.1|https?:\/\//i);
|
});
|
});
|