刘光辉
13 小时以前 0dfe84494048ce27ba8449831782128412d3eb13
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
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);
  });
});