mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
268 lines
13 KiB
TypeScript
268 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
LITE_API_STRING_ALLOWLIST,
|
|
LITE_PENDING_PATH_KEY,
|
|
LOCALE_STORAGE_KEY,
|
|
buildCaddyExample,
|
|
buildHeaders,
|
|
buildLiteConfig,
|
|
buildLitePolicy,
|
|
buildManifest,
|
|
buildNginxExample,
|
|
buildNotFoundShim,
|
|
buildReadme,
|
|
buildRedirects,
|
|
buildRootRedirect,
|
|
collectApiStrings,
|
|
discoverBuiltLocales,
|
|
normalizeBasePath,
|
|
parseBool,
|
|
unexpectedApiStrings,
|
|
} from '../../scripts/lite/lib.mjs';
|
|
import { collectTestPaths, planRemovals, runPrepare } from '../../scripts/lite/prepare.mjs';
|
|
import { runPostbuild } from '../../scripts/lite/postbuild.mjs';
|
|
import { verifyExport } from '../../scripts/lite/verify.mjs';
|
|
import { LITE_PENDING_PATH_KEY as APP_LITE_PENDING_PATH_KEY } from '../lite';
|
|
|
|
/** The scripts take `process.env`-shaped input; tests pass plain objects. */
|
|
function env(values: Record<string, string> = {}): NodeJS.ProcessEnv {
|
|
return values as NodeJS.ProcessEnv;
|
|
}
|
|
|
|
function makeRepo(): string {
|
|
const root = mkdtempSync(join(tmpdir(), 'bulwark-lite-'));
|
|
const touch = (rel: string, content = '') => {
|
|
const full = join(root, ...rel.split('/'));
|
|
mkdirSync(join(full, '..'), { recursive: true });
|
|
writeFileSync(full, content);
|
|
};
|
|
touch('proxy.ts');
|
|
touch('app/api/config/route.ts');
|
|
touch('app/(main)/admin/page.tsx');
|
|
touch('app/(main)/[locale]/mail/[[...segments]]/page.tsx');
|
|
touch('lib/__tests__/foo.test.ts');
|
|
touch('lib/keep.ts');
|
|
touch('stores/bar.test.tsx');
|
|
touch('scripts/lite/lib.mjs');
|
|
touch('scripts/lite/smoke/lite.spec.ts');
|
|
touch('node_modules/pkg/x.test.js');
|
|
touch('repos/other/y.test.ts');
|
|
touch('VERSION', '1.10.0\n');
|
|
return root;
|
|
}
|
|
|
|
describe('lite build helpers', () => {
|
|
it('parses booleans and base paths', () => {
|
|
expect(parseBool('true')).toBe(true);
|
|
expect(parseBool('1')).toBe(true);
|
|
expect(parseBool('no')).toBe(false);
|
|
expect(parseBool(undefined, true)).toBe(true);
|
|
expect(normalizeBasePath('/webmail/')).toBe('/webmail');
|
|
expect(normalizeBasePath('')).toBe('');
|
|
expect(() => normalizeBasePath('webmail')).toThrow(/start with/);
|
|
});
|
|
|
|
it('derives config.json from the LITE_* inputs with safe defaults', () => {
|
|
const empty = buildLiteConfig({});
|
|
expect(empty.jmapServerUrl).toBe('');
|
|
expect(empty.allowCustomJmapEndpoint).toBe(true);
|
|
expect(empty.rememberMeEnabled).toBe(true);
|
|
expect(empty.demoMode).toBe(false);
|
|
expect(empty.appName).toBe('Bulwark Webmail');
|
|
|
|
const fixed = buildLiteConfig({ LITE_JMAP_SERVER_URL: 'https://mail.example.com/', LITE_APP_NAME: 'Acme', LITE_DEMO_MODE: 'true', LITE_REMEMBER_ME: 'false' });
|
|
expect(fixed.jmapServerUrl).toBe('https://mail.example.com');
|
|
expect(fixed.allowCustomJmapEndpoint).toBe(false);
|
|
expect(fixed.appName).toBe('Acme');
|
|
expect(fixed.demoMode).toBe(true);
|
|
expect(fixed.rememberMeEnabled).toBe(false);
|
|
expect(buildLitePolicy().features).toEqual({ pluginsEnabled: false, sidebarAppsEnabled: false });
|
|
});
|
|
|
|
it('prefixes manifest and redirect paths with the base path', () => {
|
|
const manifest = buildManifest({ appName: 'Acme', basePath: '/webmail' });
|
|
expect(manifest.start_url).toBe('/webmail/');
|
|
expect(manifest.icons[0].src).toBe('/webmail/icon-192x192.png');
|
|
|
|
const redirects = buildRedirects({ basePath: '/webmail', locales: ['en', 'de'] });
|
|
expect(redirects).toContain('/webmail/en/mail/* /webmail/en/mail/index.html 200');
|
|
expect(redirects).toContain('/webmail/de/settings/* /webmail/de/settings/index.html 200');
|
|
expect(redirects).not.toContain('/en/login/*');
|
|
});
|
|
|
|
it('writes a root shim that knows every built locale and the fallback', () => {
|
|
const html = buildRootRedirect({ basePath: '/webmail', locales: ['de', 'en', 'zh-TW'], defaultLocale: 'en' });
|
|
expect(html).toContain('var locales = ["de","en","zh-TW"]');
|
|
expect(html).toContain('var fallback = "en"');
|
|
expect(html).toContain('var base = "/webmail"');
|
|
expect(html).toContain('href="/webmail/en/"');
|
|
// The language picked in Settings (zustand persist key of stores/locale-store.ts) wins.
|
|
expect(html).toContain(`localStorage.getItem("${LOCALE_STORAGE_KEY}")`);
|
|
expect(LOCALE_STORAGE_KEY).toBe('locale-storage');
|
|
// An unknown default falls back to the first built locale.
|
|
expect(buildRootRedirect({ locales: ['de'], defaultLocale: 'fr' })).toContain('var fallback = "de"');
|
|
});
|
|
|
|
it('emits a CSP that allows inline scripts and the configured server only', () => {
|
|
const headers = buildHeaders({ basePath: '', connectSrc: 'https://mail.example.com' });
|
|
expect(headers).toContain("script-src 'self' 'unsafe-inline'");
|
|
expect(headers).toContain("connect-src 'self' https://mail.example.com");
|
|
expect(headers).toContain("frame-ancestors 'none'");
|
|
expect(headers).toContain('/_next/static/*');
|
|
expect(buildHeaders({ basePath: '/w', connectSrc: '*' })).toMatch(/^\/w\/\*/);
|
|
});
|
|
|
|
it('writes a 404 shim that parks deep links for known locale/surface pairs only', () => {
|
|
const html = buildNotFoundShim({ basePath: '/webmail', locales: ['de', 'en'] });
|
|
expect(html).toContain('var base = "/webmail"');
|
|
expect(html).toContain('var locales = ["de","en"]');
|
|
expect(html).toContain('var surfaces = ["mail","calendar","contacts","files","settings"]');
|
|
expect(html).toContain(`sessionStorage.setItem("${LITE_PENDING_PATH_KEY}"`);
|
|
// Only allowlisted segments are ever assembled into the redirect target.
|
|
expect(html).toContain('var target = base + "/" + parts[0] + "/" + parts[1] + "/"');
|
|
expect(html).toContain('location.replace(target)');
|
|
expect(html).toContain('href="/webmail/"');
|
|
// The key the shim writes is the one the app reads.
|
|
expect(LITE_PENDING_PATH_KEY).toBe(APP_LITE_PENDING_PATH_KEY);
|
|
});
|
|
|
|
it('renders host snippets and the README for the built variant', () => {
|
|
expect(buildNginxExample({ basePath: '' })).toContain('(?<surface>mail|calendar|contacts|files|settings)');
|
|
// Root-relative everywhere: a sub-path mount means the files live in <root><basePath>
|
|
// (alias does not work inside a regex location and produced a redirect cycle).
|
|
const nginxSub = buildNginxExample({ basePath: '/w' });
|
|
expect(nginxSub).not.toContain('alias ');
|
|
expect(nginxSub).toContain('Unzip the archive into /var/www/bulwark-lite/w');
|
|
expect(nginxSub).toContain('try_files $uri $uri/ $uri/index.html /w/$locale/$surface/index.html');
|
|
expect(nginxSub).toContain('error_page 404 /w/404.html');
|
|
expect(nginxSub).toContain('default_type application/manifest+json');
|
|
expect(buildCaddyExample({ basePath: '' })).toContain('try_files {path} {path}/ {path}/index.html /{re.surface.1}/{re.surface.2}/index.html');
|
|
expect(buildCaddyExample({ basePath: '/w' })).toContain('rewrite @notfound /w/404.html');
|
|
expect(buildReadme({ version: '1.10.0', commit: 'abc1234', basePath: '/w', locales: ['en'] })).toContain('unzip into `<web root>/w`');
|
|
const readme = buildReadme({ version: '1.10.0', commit: 'abc1234', basePath: '/w', locales: ['en'], jmapServerUrl: 'https://m.example', demoMode: true });
|
|
expect(readme).toContain('# Bulwark Lite 1.10.0 (abc1234)');
|
|
expect(readme).toContain('Demo mode is ON');
|
|
expect(readme).toContain('permissive-cors = true');
|
|
expect(readme).toContain('currently `https://m.example`');
|
|
});
|
|
|
|
it('flags server endpoints outside the documented allowlist', () => {
|
|
expect(unexpectedApiStrings(['/api/config', '/api/plugins/x', '/api/push/register/web'])).toEqual([]);
|
|
expect(unexpectedApiStrings(['/api/brand-new-thing', '/api/config'])).toEqual(['/api/brand-new-thing']);
|
|
expect(LITE_API_STRING_ALLOWLIST).toContain('/api/config');
|
|
});
|
|
});
|
|
|
|
describe('prepare / postbuild / verify against a fake checkout', () => {
|
|
it('plans the server-only trees and every test file, skipping node_modules, repos and scripts', () => {
|
|
const root = makeRepo();
|
|
try {
|
|
const tests = collectTestPaths(root).map((p) => p.slice(root.length + 1).replace(/\\/g, '/'));
|
|
expect(tests.sort()).toEqual(['lib/__tests__', 'stores/bar.test.tsx']);
|
|
const plan = planRemovals(root).map((p) => p.slice(root.length + 1).replace(/\\/g, '/'));
|
|
expect(plan).toContain('proxy.ts');
|
|
expect(plan).toContain('app/api');
|
|
expect(plan).toContain('app/(main)/admin');
|
|
expect(plan).not.toContain('app/(main)/[locale]/mail/[[...segments]]/page.tsx');
|
|
expect(plan).not.toContain('scripts/lite/smoke/lite.spec.ts');
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('refuses to delete outside CI unless --in-place is given, and --dry-run never deletes', () => {
|
|
const root = makeRepo();
|
|
try {
|
|
const log = () => {};
|
|
expect(() => runPrepare({ root, argv: [], env: env(), log })).toThrow(/refusing/);
|
|
runPrepare({ root, argv: ['--dry-run'], env: env(), log });
|
|
expect(existsSync(join(root, 'proxy.ts'))).toBe(true);
|
|
|
|
const result = runPrepare({ root, argv: ['--in-place'], env: env(), log });
|
|
expect(result.removed.length).toBeGreaterThan(0);
|
|
expect(existsSync(join(root, 'proxy.ts'))).toBe(false);
|
|
expect(existsSync(join(root, 'app', 'api'))).toBe(false);
|
|
expect(existsSync(join(root, 'lib', '__tests__'))).toBe(false);
|
|
expect(existsSync(join(root, 'lib', 'keep.ts'))).toBe(true);
|
|
expect(existsSync(join(root, 'scripts', 'lite', 'smoke', 'lite.spec.ts'))).toBe(true);
|
|
expect(existsSync(join(root, 'node_modules', 'pkg', 'x.test.js'))).toBe(true);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('CI=true counts as consent', () => {
|
|
const root = makeRepo();
|
|
try {
|
|
runPrepare({ root, argv: [], env: env({ CI: 'true' }), log: () => {} });
|
|
expect(existsSync(join(root, 'proxy.ts'))).toBe(false);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('postbuild writes the deployer files for the built locales and verify accepts the result', () => {
|
|
const root = makeRepo();
|
|
try {
|
|
const out = join(root, 'out');
|
|
for (const locale of ['en', 'de']) {
|
|
for (const surface of ['mail', 'calendar', 'contacts', 'files', 'settings', 'login']) {
|
|
mkdirSync(join(out, locale, surface), { recursive: true });
|
|
writeFileSync(join(out, locale, surface, 'index.html'), '<html></html>');
|
|
}
|
|
writeFileSync(join(out, locale, 'index.html'), '<html></html>');
|
|
}
|
|
mkdirSync(join(out, '_next', 'static', 'chunks'), { recursive: true });
|
|
writeFileSync(join(out, '_next', 'static', 'chunks', 'app.js'), 'fetch("/api/config");fetch(`/api/plugins`);');
|
|
mkdirSync(join(out, 'branding'), { recursive: true });
|
|
writeFileSync(join(out, '404.html'), '<html></html>');
|
|
|
|
expect(discoverBuiltLocales(out)).toEqual(['de', 'en']);
|
|
|
|
const result = runPostbuild({
|
|
root,
|
|
env: env({ LITE_JMAP_SERVER_URL: 'https://mail.example.com', LITE_APP_NAME: 'Acme', NEXT_PUBLIC_BASE_PATH: '/w', GIT_COMMIT: 'abcdef1234567' }),
|
|
log: () => {},
|
|
});
|
|
expect(result.locales).toEqual(['de', 'en']);
|
|
expect(result.basePath).toBe('/w');
|
|
for (const file of ['config.json', 'policy.json', 'manifest.webmanifest', 'index.html', '_redirects', '_headers', 'nginx.conf.example', 'Caddyfile.example', 'LITE-README.md', 'lite-build.json']) {
|
|
expect(existsSync(join(out, file)), file).toBe(true);
|
|
}
|
|
expect(collectApiStrings(join(out, '_next', 'static'))).toEqual(['/api/config', '/api/plugins']);
|
|
// Next's default not-found page was replaced by the park-and-replay shim.
|
|
expect(readFileSync(join(out, '404.html'), 'utf8')).toContain(LITE_PENDING_PATH_KEY);
|
|
|
|
const verdict = verifyExport({ root });
|
|
expect(verdict.problems).toEqual([]);
|
|
expect(verdict.locales).toEqual(['de', 'en']);
|
|
|
|
// An export whose 404.html is still Next's default fails verification.
|
|
writeFileSync(join(out, '404.html'), '<html>404: This page could not be found.</html>');
|
|
expect(verifyExport({ root }).problems.join('\n')).toContain('404.html is not the Lite shim');
|
|
writeFileSync(join(out, '404.html'), buildNotFoundShim({ basePath: '/w', locales: ['de', 'en'] }));
|
|
|
|
// A new, undocumented endpoint in a chunk is a verification failure.
|
|
writeFileSync(join(out, '_next', 'static', 'chunks', 'new.js'), 'apiFetch("/api/brand-new")');
|
|
expect(verifyExport({ root }).problems.join('\n')).toContain('/api/brand-new');
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('verify lists what is missing from an incomplete export', () => {
|
|
const root = makeRepo();
|
|
try {
|
|
mkdirSync(join(root, 'out'), { recursive: true });
|
|
const { problems } = verifyExport({ root });
|
|
expect(problems).toContain('no locale shells found (out/<locale>/mail/index.html)');
|
|
expect(problems).toContain('missing out/config.json');
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|