Files
webmail/lib/__tests__/lite-scripts.test.ts
T
Linus Rath d9ffe8a76e fix: reach directly shared files and keep others' files on a failed copy
Fixes from the review of the Files work (1cf9944b..a9a25b94).

- A file shared on its own, or one below a folder its owner took back,
  could not be reached. Once Stalwart 1.0's placeholder folders were
  left out it became a root of "Shared with me", which listed folders
  only, and search opened it on the drive root. 0.16 lists such files
  as roots too. Each owner's files shared on their own are now listed
  on one page under "Shared with me" ("Files from <name>"), where they
  open, preview and download like any other file and nothing can be
  added; two of the same name get a number. Search opens a shared hit
  where it is listed, and links into a shared drive name the node by id
  (`/files?shared=<owner>:<id>`), so a shared folder or page also
  survives a reload. A Files page that is already open takes the link
  from search directly, since Next keeps it mounted when only the query
  changes.
- A copy that fails part-way destroys only the nodes it made, the
  deepest first and without `onDestroyRemoveChildren`, one call per
  level because 0.16 refuses a folder destroyed in the same call as
  its children. It used to destroy the new folder with everything
  below it, so whatever someone put there during a long copy was
  deleted for good. A folder holding such nodes now stays, and the
  error says part of the copy was left in place.
- The document editor opens read-only on 0.16 for a sharee without the
  rename right. 0.16.6 to 0.16.23 refuse any change a sharee makes
  without Modify, so every save of such a document failed with a 500.
  1.0, told apart by its `maxFileNodeDepth`, still asks for
  `mayModifyContent` alone.
- Ctrl+C with only symlinks selected empties the clipboard instead of
  keeping an earlier cut for the next paste.
- A paste reads each account's file tree once, not once per pasted
  item for the listing and 1.0's depth check.
- The Stalwart bundle's README says that on older 0.16 releases and
  the 1.0 pre-releases a failed update also drops the cached copy, and
  the next restart or reload of the Applications downloads it again,
  not only a later `UpdateApps`. The static README says a restart
  applies the CORS setting on the current 1.0 pre-releases, where
  `ReloadSettings` fails with the stock spam-filter rules.
2026-09-25 22:16:19 +02:00

800 lines
45 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,
LITE_TARGETS,
LOCALE_STORAGE_KEY,
STALWART_BASE_HREF_LITERAL,
STALWART_CONTENT_TYPES,
STALWART_DEFAULT_PREFIX,
STALWART_ENTRY_MARKER,
STALWART_OAUTH_META_LITERAL,
STALWART_ZIP_NAME,
buildCaddyExample,
buildHeaders,
buildLiteConfig,
buildLitePolicy,
buildManifest,
buildNginxContainerTemplate,
buildNginxExample,
buildNotFoundShim,
buildReadme,
buildRedirects,
buildRootRedirect,
buildStalwartEntry,
buildStalwartManifest,
buildStalwartReadme,
buildZip,
collectApiStrings,
collectZipEntries,
discoverBuiltLocales,
discoverShellDirs,
emulateStalwartRequest,
findSegmentPrefetchDirs,
flightTextRowsWithNeedles,
inlineJson,
installStalwartFetchShim,
isReservedStalwartSegment,
listZipEntries,
makeBuildId,
normalizeBasePath,
parseBool,
parseLiteTarget,
readZipEntry,
resolveStalwartBoot,
rewriteFlightForMount,
rewriteShellForMount,
stalwartContentType,
stalwartEntryProblems,
stalwartOauthMeta,
stalwartPrefixProblems,
stalwartRewriteIndex,
stalwartZipEntryProblems,
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 { stageContainer } from '../../scripts/lite/container.mjs';
import { LITE_PENDING_PATH_KEY as APP_LITE_PENDING_PATH_KEY } from '../lite';
import { applyLiteConfig } from '../lite-config';
/** 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).not.toHaveProperty('allowCustomJmapEndpoint');
expect(applyLiteConfig(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).not.toHaveProperty('allowCustomJmapEndpoint');
expect(applyLiteConfig(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 });
expect(buildLiteConfig({ LITE_ALLOW_CUSTOM_ENDPOINT: 'true' }).allowCustomJmapEndpoint).toBe(true);
expect(buildLiteConfig({ LITE_ALLOW_CUSTOM_ENDPOINT: 'false' }).allowCustomJmapEndpoint).toBe(false);
});
it('hides the server field once a deployer fills in jmapServerUrl of the shipped config.json (#1087)', () => {
const shipped = buildLiteConfig({});
const edited = { ...shipped, jmapServerUrl: 'https://mail.example.com' };
expect(applyLiteConfig(edited).allowCustomJmapEndpoint).toBe(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');
// Since 0.16 CORS is the registry setting Http.usePermissiveCors (the same
// on 1.0); the old TOML key is not read.
expect(readme).toContain('Settings > Network > HTTP > Security');
expect(readme).toContain('stalwart-cli update Http --field usePermissiveCors=true');
expect(readme).toContain('{"update": {"singleton": {"usePermissiveCors": true}}}');
expect(readme).toContain('ReloadSettings');
// The 1.0 pre-releases can't reload settings while the stock spam-filter
// rules use a function 1.0 removed; a restart applies the change there.
expect(readme).toMatch(/On the current 1\.0 pre-releases `ReloadSettings` fails while the\s+stock spam-filter rules are loaded, so restart Stalwart there instead\./);
expect(readme).not.toContain('permissive-cors = true');
expect(readme).toContain('currently `https://m.example`');
});
it('renders the container nginx template with every header in every header-bearing location', () => {
const conf = buildNginxContainerTemplate();
expect(conf).toContain('listen 8080;');
// Filled in by the image: the IPv6 listen line and the CSP's connect-src.
expect(conf).toContain('\n ${LITE_LISTEN_IPV6}\n');
expect(conf).toContain("connect-src 'self' ${LITE_CSP_CONNECT_SRC}; frame-src");
// Every other $ belongs to nginx; the image limits envsubst to LITE_*.
expect([...conf.matchAll(/\$\{(\w+)\}/g)].map((m) => m[1]).filter((name) => !name.startsWith('LITE_'))).toEqual([]);
expect(conf).toContain('try_files $uri $uri/ $uri/index.html /$locale/$surface/index.html;');
expect(conf).toContain('(?<surface>mail|calendar|contacts|files|settings)');
expect(conf).toContain('error_page 404 /404.html;');
expect(conf).toContain('absolute_redirect off;');
// add_header in a location drops the inherited set: server, /_next/static/ and /connector.json each carry it.
expect(conf.match(/add_header Content-Security-Policy /g)).toHaveLength(3);
expect(conf.match(/add_header X-Content-Type-Options nosniff always;/g)).toHaveLength(3);
// The CSP is one double-quoted nginx string.
expect(conf).not.toMatch(/Content-Security-Policy "[^"\n]*"[^;\n]*"/);
});
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('stages the container image content from a root-mounted static export only', () => {
const root = makeRepo();
try {
const out = join(root, 'out');
for (const surface of ['mail', 'calendar', 'contacts', 'files', 'settings', 'login']) {
mkdirSync(join(out, 'en', surface), { recursive: true });
writeFileSync(join(out, 'en', surface, 'index.html'), '<html></html>');
}
expect(() => stageContainer({ root, log: () => {} })).toThrow(/run `npm run build:lite` first/);
runPostbuild({ root, env: env({ NEXT_PUBLIC_BASE_PATH: '/w' }), log: () => {} });
expect(() => stageContainer({ root, log: () => {} })).toThrow(/serves from \/, out\/ was built with NEXT_PUBLIC_BASE_PATH=\/w/);
runPostbuild({ root, env: env({}), log: () => {} });
const dest = join(root, 'image');
// A stale file from an earlier staging must not survive.
mkdirSync(join(dest, 'html'), { recursive: true });
writeFileSync(join(dest, 'html', 'stale.txt'), '');
stageContainer({ root, dest, log: () => {} });
expect(readFileSync(join(dest, 'default.conf.template'), 'utf8')).toBe(buildNginxContainerTemplate());
for (const file of ['config.json', 'policy.json', 'connector.json', '404.html', 'lite-build.json', 'en/mail/index.html']) {
expect(existsSync(join(dest, 'html', ...file.split('/'))), file).toBe(true);
}
for (const file of ['_redirects', '_headers', 'nginx.conf.example', 'Caddyfile.example', 'LITE-README.md', 'stale.txt']) {
expect(existsSync(join(dest, 'html', file)), file).toBe(false);
}
// out/ itself is left alone: the zip is packed from it.
expect(existsSync(join(out, '_headers'))).toBe(true);
} 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 });
}
});
});
// ---------------------------------------------------------------------------
// Stalwart Application target
// ---------------------------------------------------------------------------
function entriesOf(files: Record<string, string>): Map<string, Buffer> {
return new Map(Object.entries(files).map(([name, text]) => [name, Buffer.from(text)]));
}
describe('Stalwart target: routing rules and constants', () => {
it('parses the build target from argv and env', () => {
expect(parseLiteTarget([], {})).toBe('static');
expect(parseLiteTarget(['--target=stalwart'], {})).toBe('stalwart');
expect(parseLiteTarget(['--target', 'Stalwart'], {})).toBe('stalwart');
expect(parseLiteTarget([], { LITE_TARGET: 'stalwart' })).toBe('stalwart');
expect(parseLiteTarget(['--target=static'], { LITE_TARGET: 'stalwart' })).toBe('static');
expect(() => parseLiteTarget(['--target=netlify'], {})).toThrow(/unknown Lite target/);
expect(LITE_TARGETS).toEqual(['static', 'stalwart']);
});
it('knows which first segments Stalwart keeps for itself', () => {
for (const s of ['jmap', 'api', 'auth', 'dav', '.well-known', 'login', 'device', 'metrics', 'admin', 'account', 'mail', 'calendar', 'AutoDiscover']) {
expect(isReservedStalwartSegment(s), s).toBe(true);
}
for (const s of ['webmail', 'bulwark', 'en', '_next', 'index.html']) expect(isReservedStalwartSegment(s), s).toBe(false);
expect(stalwartPrefixProblems(STALWART_DEFAULT_PREFIX)).toEqual([]);
expect(stalwartPrefixProblems('/bulwark/')).toEqual([]);
expect(stalwartPrefixProblems('/mail')[0]).toMatch(/routed by Stalwart/);
expect(stalwartPrefixProblems('/apps/webmail')[0]).toMatch(/first segment only/);
expect(stalwartPrefixProblems('/')[0]).toMatch(/empty/);
expect(stalwartPrefixProblems('/we b')[0]).toMatch(/characters/);
});
it('maps extensions exactly like application.rs', () => {
expect(stalwartContentType('index.html')).toBe('text/html');
expect(stalwartContentType('_next/static/chunks/a.js')).toBe('application/javascript');
expect(stalwartContentType('config.json')).toBe('application/json');
expect(stalwartContentType('icon.png')).toBe('image/png');
for (const name of ['en/mail/index.txt', 'a.woff2', 'manifest.webmanifest', 'x.mjs', 'x.mp3', 'x.webp', 'LICENSE']) {
expect(stalwartContentType(name), name).toBe('application/octet-stream');
}
expect(Object.keys(STALWART_CONTENT_TYPES).sort()).toEqual(['css', 'html', 'ico', 'js', 'json', 'png', 'svg', 'wasm']);
});
it('makes build ids that differ per commit and build time', () => {
const id = makeBuildId({ version: '1.10.0', commit: 'abcdef1234', now: 1_700_000_000_000 });
expect(id).toBe(`1.10.0-abcdef1-${(1_700_000_000).toString(36)}`);
expect(makeBuildId({ version: '1.10.0', commit: 'abcdef1234', now: 1_700_000_001_000 })).not.toBe(id);
expect(makeBuildId({ version: '1 0"<', commit: '' })).toMatch(/^10-local-/);
});
});
describe('Stalwart target: entry document logic (runs in the browser)', () => {
const locales = ['de', 'en', 'zh-TW'];
const shells = ['', 'auth/callback', 'calendar', 'contacts', 'files', 'login', 'mail', 'plugins/oauth/callback', 'pro', 'settings'];
it('boots the shell that owns the path and keeps the address', () => {
expect(resolveStalwartBoot('/webmail/en/mail/thread/abc', '/webmail', locales, shells, 'de')).toEqual({ locale: 'en', shell: 'en/mail/index.html', canonical: null });
expect(resolveStalwartBoot('/webmail/de/calendar/week/2026-09-17', '/webmail', locales, shells, 'en')).toEqual({ locale: 'de', shell: 'de/calendar/index.html', canonical: null });
expect(resolveStalwartBoot('/webmail/zh-TW/settings/', '/webmail', locales, shells, 'en')?.shell).toBe('zh-TW/settings/index.html');
expect(resolveStalwartBoot('/webmail/en/auth/callback', '/webmail', locales, shells, 'en')?.shell).toBe('en/auth/callback/index.html');
expect(resolveStalwartBoot('/webmail/en/', '/webmail', locales, shells, 'de')).toEqual({ locale: 'en', shell: 'en/index.html', canonical: null });
expect(resolveStalwartBoot('/webmail/en', '/webmail', locales, shells, 'de')?.shell).toBe('en/index.html');
});
it('sends locale-less and unknown paths to the preferred (or linked) locale root', () => {
expect(resolveStalwartBoot('/webmail/', '/webmail', locales, shells, 'de')).toEqual({ locale: 'de', shell: 'de/index.html', canonical: '/webmail/de/' });
expect(resolveStalwartBoot('/webmail', '/webmail', locales, shells, 'en')?.canonical).toBe('/webmail/en/');
expect(resolveStalwartBoot('/webmail/nope/x', '/webmail', locales, shells, 'en')).toEqual({ locale: 'en', shell: 'en/index.html', canonical: '/webmail/en/' });
expect(resolveStalwartBoot('/webmail/en/nope', '/webmail', locales, shells, 'de')).toEqual({ locale: 'en', shell: 'en/index.html', canonical: '/webmail/en/' });
// `auth` alone is not the auth/callback shell.
expect(resolveStalwartBoot('/webmail/en/auth', '/webmail', locales, shells, 'de')?.canonical).toBe('/webmail/en/');
});
it('boots the callback for the locale-free OAuth redirect URI', () => {
expect(resolveStalwartBoot('/webmail/oauth/callback', '/webmail', locales, shells, 'de')).toEqual({ locale: 'de', shell: 'de/auth/callback/index.html', canonical: '/webmail/de/auth/callback/' });
expect(resolveStalwartBoot('/webmail/oauth/callback/', '/webmail', locales, shells, 'en')?.shell).toBe('en/auth/callback/index.html');
expect(resolveStalwartBoot('/oauth/callback', '', locales, shells, 'en')?.canonical).toBe('/en/auth/callback/');
// Nothing else below `oauth`, and not in a bundle without the callback shell.
expect(resolveStalwartBoot('/webmail/oauth', '/webmail', locales, shells, 'en')?.shell).toBe('en/index.html');
expect(resolveStalwartBoot('/webmail/oauth/callback/x', '/webmail', locales, shells, 'en')?.shell).toBe('en/index.html');
expect(resolveStalwartBoot('/webmail/oauth/callback', '/webmail', locales, shells.filter((s: string) => s !== 'auth/callback'), 'en')?.shell).toBe('en/index.html');
});
it('works at the site root and refuses paths outside the mount', () => {
expect(resolveStalwartBoot('/en/mail', '', locales, shells, 'de')?.shell).toBe('en/mail/index.html');
expect(resolveStalwartBoot('/', '', locales, shells, 'de')?.canonical).toBe('/de/');
expect(resolveStalwartBoot('/other/en/mail', '/webmail', locales, shells, 'en')).toBeNull();
expect(resolveStalwartBoot('/webmailer/en/mail', '/webmail', locales, shells, 'en')).toBeNull();
});
it('points a prerendered shell at the mount, in markup and in the inline RSC payload', () => {
const shell = '<!DOCTYPE html><html><head><link rel="stylesheet" href="/_next/static/chunks/a.css"/>'
+ '<link rel="manifest" href="/manifest.webmanifest"/><link rel="icon" href="/branding/Bulwark_Favicon.svg"/>'
+ '<script src="/_next/static/chunks/b.js" async=""></script></head><body>'
+ '<script>self.__next_f.push([1,"2:I[1,[\\"/_next/static/chunks/c.js\\"],\\"default\\"]\\n"])</script></body></html>';
const out = rewriteShellForMount(shell, '/webmail', 'b 1', '<script>BOOT</script>');
expect(out).toContain('<head><script>BOOT</script><link');
expect(out).toContain('href="/webmail/_next/static/chunks/a.css"');
expect(out).toContain('src="/webmail/_next/static/chunks/b.js"');
expect(out).toContain('\\"/webmail/_next/static/chunks/c.js\\"');
expect(out).toContain('href="/webmail/manifest.json?v=b%201"');
expect(out).toContain('href="/webmail/branding/Bulwark_Favicon.svg"');
expect(out).not.toMatch(/"\/_next\//);
// At the site root nothing moves, the boot script still goes in.
expect(rewriteShellForMount('<head></head>', '', 'b', 'X')).toBe('<head>X</head>');
});
it('rewrites RSC payload rows but copies length-prefixed text rows byte-exactly', () => {
const text = 'ü€'; // 2 + 3 UTF-8 bytes
const tRow = `9:T${Buffer.byteLength(`${text}"/_next/x`).toString(16)},${text}"/_next/x`;
const payload = `0:["$","link",null,{"href":"/_next/static/chunks/a.css"}]\n${tRow}1:I[5,["/_next/static/chunks/b.js"],"x"]\n2:{"m":"/manifest.webmanifest","i":"/branding/i.svg"}\n`;
const out = rewriteFlightForMount(payload, '/webmail', 'id');
expect(out).toContain('{"href":"/webmail/_next/static/chunks/a.css"}');
expect(out).toContain(`${tRow}1:I[5,["/webmail/_next/static/chunks/b.js"],"x"]`);
expect(out).toContain('"m":"/webmail/manifest.json?v=id","i":"/webmail/branding/i.svg"');
// A surrogate pair counts as four bytes.
const emoji = '😀ab';
const row = `3:T${Buffer.byteLength(emoji).toString(16)},${emoji}`;
expect(rewriteFlightForMount(`${row}4:"/_next/y"\n`, '/w', 'b')).toBe(`${row}4:"/w/_next/y"\n`);
// Malformed text rows cannot loop forever.
expect(rewriteFlightForMount('5:Tzz,"/_next/"', '/w', 'b')).toBe('5:Tzz,"/_next/"');
expect(rewriteFlightForMount('', '/w', 'b')).toBe('');
});
it('finds text rows that would escape the rewrite', () => {
expect(flightTextRowsWithNeedles('0:"x"\n1:T3,abc2:"/_next/ok"\n')).toEqual([]);
const body = 'see "/_next/static/x"';
expect(flightTextRowsWithNeedles(`a:T${Buffer.byteLength(body).toString(16)},${body}`)).toEqual(['a']);
});
it('relabels and rewrites RSC payloads fetched below the mount, and nothing else', async () => {
const origin = 'https://mail.example.com';
const responses: Record<string, [string, string]> = {
[`${origin}/webmail/en/mail/index.txt`]: ['application/octet-stream', '0:I[1,["/_next/static/chunks/a.js"],"x"]\n'],
[`${origin}/webmail/en/cal/index.txt`]: ['text/plain', '0:"/_next/b.js"\n'],
[`${origin}/webmail/en/x.txt`]: ['text/html', '<html>entry</html>'],
[`${origin}/other/y.txt`]: ['application/octet-stream', '"/_next/z"'],
[`${origin}/webmail/font.woff2`]: ['application/octet-stream', 'bin'],
};
const win = {
location: { origin },
fetch: async (url: string) => {
const [type, body] = responses[url];
const res = new Response(body, { status: 200, headers: { 'content-type': type } });
Object.defineProperty(res, 'url', { value: url });
return res;
},
} as unknown as Window & { fetch: (u: string) => Promise<Response> };
installStalwartFetchShim(win, '/webmail', 'b', rewriteFlightForMount);
installStalwartFetchShim(win, '/webmail', 'b', rewriteFlightForMount); // idempotent
const rsc = await win.fetch(`${origin}/webmail/en/mail/index.txt`);
expect(rsc.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(rsc.url).toBe(`${origin}/webmail/en/mail/index.txt`);
expect(await rsc.text()).toBe('0:I[1,["/webmail/_next/static/chunks/a.js"],"x"]\n');
expect(await (await win.fetch(`${origin}/webmail/en/cal/index.txt`)).text()).toBe('0:"/webmail/_next/b.js"\n');
const html = await win.fetch(`${origin}/webmail/en/x.txt`);
expect(html.headers.get('content-type')).toBe('text/html');
expect(await (await win.fetch(`${origin}/other/y.txt`)).text()).toBe('"/_next/z"');
expect((await win.fetch(`${origin}/webmail/font.woff2`)).headers.get('content-type')).toBe('application/octet-stream');
});
it('serialises values for inline scripts without a </script> breakout', () => {
expect(inlineJson(['</script>', 'a\u2028b'])).toBe('["\\u003c/script>","a\\u2028b"]');
});
});
describe('Stalwart target: the entry document', () => {
const html = buildStalwartEntry({ locales: ['de', 'en'], shells: ['', 'mail', 'calendar'], defaultLocale: 'en', buildId: '1.10.0-abc-x', appName: 'Acme <Mail>' });
it('carries both literals Stalwart rewrites exactly once and nothing root-absolute', () => {
expect(html.split(STALWART_BASE_HREF_LITERAL)).toHaveLength(2);
expect(html.split(STALWART_OAUTH_META_LITERAL)).toHaveLength(2);
expect(html).toContain(STALWART_ENTRY_MARKER);
expect(html).toContain('<title>Acme &lt;Mail&gt;</title>');
expect(stalwartEntryProblems(html)).toEqual([]);
// The inline script is valid JavaScript.
const script = html.slice(html.indexOf('<script>') + 8, html.lastIndexOf('</script>'));
expect(() => new Function(script)).not.toThrow();
expect(script).toContain('var BUILD_ID = "1.10.0-abc-x"');
});
it('survives both Stalwart rewrites, including the pre-0.16.19 replace-all', () => {
const current = stalwartRewriteIndex(html, 'webmail', 'my"client');
expect(current).toContain('<base href="/webmail/" />');
expect(current).toContain('<meta name="oauth-client-id" content="my&quot;client" />');
const legacy = stalwartRewriteIndex(html, 'webmail', 'ignored', { legacy: true });
expect(legacy).toContain('<base href="/webmail/" />');
expect(legacy).toContain('<meta name="oauth-client-id" content="" />');
expect(stalwartOauthMeta('a&b<c>')).toBe('<meta name="oauth-client-id" content="a&amp;b&lt;c&gt;"');
});
it('flags a broken entry', () => {
expect(stalwartEntryProblems('<html></html>').join('\n')).toMatch(/exactly once[\s\S]*exactly once[\s\S]*not the Stalwart entry/);
const doubled = html.replace('<title>', `${STALWART_BASE_HREF_LITERAL} /><title>`);
expect(stalwartEntryProblems(doubled)[0]).toMatch(/found 2/);
expect(stalwartEntryProblems(html.replace('<title>', '<script src="/_next/static/x.js"></script><title>')).join('\n')).toMatch(/root-absolute src="\/_next/);
expect(stalwartEntryProblems(html.replace('<title>', '<script>fetch("/config.json")</script><title>')).join('\n')).toMatch(/config\.json absolutely/);
});
it('ships a manifest with relative URLs', () => {
const manifest = buildStalwartManifest({ appName: 'Acme' });
expect(manifest.start_url).toBe('./');
expect(manifest.scope).toBe('./');
expect(manifest.icons.every((icon: { src: string }) => !icon.src.startsWith('/'))).toBe(true);
});
it('documents the install, reserved prefixes, updates and the redirect URI', () => {
const readme = buildStalwartReadme({ version: '1.10.0', commit: 'abc1234', locales: ['en'], buildId: 'b' });
expect(readme).toContain(`releases/latest/download/${STALWART_ZIP_NAME}`);
expect(readme).toContain('stalwart-cli create Application');
// The CLI wants JSON (verified against stalwart-cli 1.0.12), and nothing is mounted before UpdateApps.
expect(readme).toContain(`--field 'urlPrefix={"/webmail":true}'`);
expect(readme).toContain('stalwart-cli create Action/UpdateApps');
expect(readme).toContain('"@type": "UpdateApps"');
expect(readme).toContain('x:Application/set');
expect(readme).toContain('sysApplicationCreate');
expect(readme).toContain('actionUpdateApps');
expect(readme).toContain('autoUpdateFrequency');
// Release tags carry no "v" prefix (since 1.3.0); the v-URL 404s.
expect(readme).toContain(`releases/download/1.10.0/${STALWART_ZIP_NAME}`);
expect(readme).not.toContain('releases/download/v1.10.0/');
expect(readme).toContain('https://mail.example.com/webmail/');
for (const segment of ['`mail`', '`calendar`', '`admin`', '`account`']) expect(readme).toContain(segment);
});
it('states the Stalwart floor and what a failed download does on each version', () => {
const readme = buildStalwartReadme({ version: '1.10.0', commit: 'abc1234', locales: ['en'], buildId: 'b' });
expect(readme).toContain('Requires: Stalwart 0.16.6 or later (0.16.19 for `oauthClientId`).');
// Only 0.16.23 keeps the running bundle; 0.16.0-0.16.22 and the 1.0
// pre-releases unmount the Application and delete the cached zip, so any
// later unpack downloads again: a restart, UpdateApps, or a reload of the
// Applications (cluster peers run one after UpdateApps). A restart with an
// expired cache and an unreachable URL leaves it unmounted everywhere.
expect(readme).not.toContain('A failed download keeps the previous bundle online');
expect(readme).toMatch(/On 0\.16\.23 the\s+running bundle stays online/);
expect(readme).not.toContain('until a later `UpdateApps` succeeds');
expect(readme).toMatch(/Older 0\.16 releases and the 1\.0 pre-releases\s+unmount the Application and drop the cached copy\./);
expect(readme).toMatch(/The next restart,\s+`UpdateApps` or reload of the Applications \(the other nodes of a cluster\s+reload them after `UpdateApps`\) downloads `resourceUrl` again and mounts the\s+bundle once that works\./);
expect(readme).toMatch(/On every version, a restart after the cached copy\s+has expired/);
expect(readme).toMatch(/check that `resourceUrl` downloads before running\s+`UpdateApps`/);
});
});
describe('Stalwart target: zip and emulator', () => {
it('writes a zip that its own reader, jszip and Stalwart-style lookups agree on', async () => {
const entries = [
{ name: 'index.html', data: Buffer.from('<base href="/" />'.repeat(50)) },
{ name: '_next/static/chunks/a.js', data: Buffer.from('x') },
{ name: 'en/mail/index.txt', data: Buffer.from('ü'.repeat(100)) },
];
const zip = buildZip(entries);
const listed = listZipEntries(zip);
expect(listed.map((e) => e.name)).toEqual(entries.map((e) => e.name));
expect(listed[0].method).toBe(8);
expect(listed[1].method).toBe(0);
for (let i = 0; i < entries.length; i++) expect(readZipEntry(zip, listed[i]).equals(entries[i].data)).toBe(true);
const JSZip = (await import('jszip')).default;
const parsed = await JSZip.loadAsync(zip);
expect(await parsed.file('en/mail/index.txt')!.async('string')).toBe('ü'.repeat(100));
// Identical input, identical bytes.
expect(buildZip(entries).equals(zip)).toBe(true);
expect(() => listZipEntries(Buffer.from('nope'))).toThrow(/not a zip/);
});
it('flags zip layouts Stalwart would serve wrongly', () => {
expect(stalwartZipEntryProblems(['index.html', '_next/a.js', 'en/mail/index.html'])).toEqual([]);
expect(stalwartZipEntryProblems(['bulwark/index.html'])[0]).toMatch(/archive root/);
const problems = stalwartZipEntryProblems(['index.html', '404.html', '404/index.html', '_redirects', 'api/x.json', 'a\\b', '../x']).join('\n');
expect(problems).toMatch(/404\.html belongs to the static-host target/);
expect(problems).toMatch(/404\/index\.html belongs/);
expect(problems).toMatch(/_redirects belongs/);
expect(problems).toMatch(/top-level entry api shares its name/);
expect(problems).toMatch(/unsafe entry name "a\\\\b"/);
expect(problems).toMatch(/unsafe entry name "\.\.\/x"/);
});
it('emulates Stalwart request handling for Applications', () => {
const entry = buildStalwartEntry({ locales: ['en'], shells: ['', 'mail'], buildId: 'b' });
const entries = entriesOf({ 'index.html': entry, 'en/mail/index.html': '<html>shell</html>', 'en/mail/index.txt': 'rsc', 'config.json': '{}' });
const serve = (pathname: string, extra: Partial<Parameters<typeof emulateStalwartRequest>[0]> = {}) =>
emulateStalwartRequest({ pathname, entries, prefixes: ['/webmail', 'bulwark/'], oauthClientId: 'cid', ...extra });
expect(serve('/webmail')).toMatchObject({ status: 302, headers: { location: '/webmail/' } });
for (const path of ['/webmail/', '/webmail/en/mail/', '/webmail/en/mail/thread/1', '/webmail/nope']) {
const res = serve(path);
expect(res.status, path).toBe(200);
expect(res.headers['cache-control'], path).toBe('no-cache');
expect(res.headers['content-type'], path).toBe('text/html');
expect(res.body.toString(), path).toContain('<base href="/webmail/" />');
expect(res.body.toString(), path).toContain('<meta name="oauth-client-id" content="cid" />');
}
expect(serve('/bulwark/en/').body.toString()).toContain('<base href="/bulwark/" />');
const exact = serve('/webmail/en/mail/index.html');
expect(exact.headers['cache-control']).toBe('public, max-age=31536000, immutable');
expect(exact.body.toString()).toBe('<html>shell</html>');
expect(serve('/webmail/index.html').headers['cache-control']).toBe('no-cache');
expect(serve('/webmail/en/mail/index.txt').headers['content-type']).toBe('application/octet-stream');
expect(serve('/webmail/config.json').headers['content-type']).toBe('application/json');
for (const path of ['/', '/mail/x', '/jmap', '/api/auth', '/other/']) expect(serve(path).status, path).toBe(404);
expect(serve('/webmail/', { oauthClientId: '' }).body.toString()).toContain('<meta name="oauth-client-id" content="" />');
});
it('lists the shells below a locale', () => {
const root = mkdtempSync(join(tmpdir(), 'bulwark-lite-shells-'));
try {
for (const dir of ['en', 'en/mail', 'en/auth/callback', 'en/mail/__next.x', 'en/_not-found']) {
mkdirSync(join(root, ...dir.split('/')), { recursive: true });
writeFileSync(join(root, ...dir.split('/'), 'index.html'), '');
}
mkdirSync(join(root, 'en', 'auth'), { recursive: true });
expect(discoverShellDirs(root, 'en')).toEqual(['', 'auth/callback', 'mail']);
expect(discoverShellDirs(root, 'fr')).toEqual([]);
expect(collectZipEntries(join(root, 'en')).map((e) => e.name)).toEqual(['_not-found/index.html', 'auth/callback/index.html', 'index.html', 'mail/__next.x/index.html', 'mail/index.html']);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('writes a same-origin config for the Stalwart target', () => {
const config = buildLiteConfig({}, { target: 'stalwart' });
expect(config.jmapServerUrl).toBe('');
expect(config.allowCustomJmapEndpoint).toBe(false);
expect(config.rememberMeEnabled).toBe(true);
expect(config.demoMode).toBe(false);
expect(config._comment).toMatch(/Read-only inside the Application bundle/);
expect(buildLiteConfig({ LITE_ALLOW_CUSTOM_ENDPOINT: 'true' }, { target: 'stalwart' }).allowCustomJmapEndpoint).toBe(true);
});
});
describe('Stalwart target: postbuild + verify against a fake checkout', () => {
function makeExport(root: string) {
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><head></head></html>');
writeFileSync(join(out, locale, surface, 'index.txt'), '0:I[1,["/_next/static/chunks/a.js"],"x"]\n1:T3,abc');
// Next 16.3's nested segment-prefetch tree (requested with dots, never read) and the flat files.
mkdirSync(join(out, locale, surface, '__next.!KG1haW4p', '$d$locale'), { recursive: true });
writeFileSync(join(out, locale, surface, '__next.!KG1haW4p', '$d$locale.txt'), 'seg');
writeFileSync(join(out, locale, surface, '__next._tree.txt'), 'tree');
}
writeFileSync(join(out, locale, 'index.html'), '<html></html>');
}
mkdirSync(join(out, '_next', 'static', 'chunks'), { recursive: true });
mkdirSync(join(out, '_next', 'static', 'media'), { recursive: true });
writeFileSync(join(out, '_next', 'static', 'chunks', 'app.js'), 'fetch("/api/config")');
writeFileSync(join(out, '_next', 'static', 'media', 'pdf.worker.min.abc.mjs'), 'worker()');
mkdirSync(join(out, 'branding'), { recursive: true });
mkdirSync(join(out, '404'), { recursive: true });
writeFileSync(join(out, '404', 'index.html'), 'next 404');
writeFileSync(join(out, '404.html'), 'next 404');
return out;
}
it('writes the entry, drops static-host files, copies .mjs as .js, packs and verifies the zip', () => {
const root = makeRepo();
try {
const out = makeExport(root);
const result = runPostbuild({ root, env: env({ LITE_TARGET: 'stalwart', NEXT_PUBLIC_LITE_BUILD_ID: 'bid-1', GIT_COMMIT: 'abcdef1234567' }), log: () => {} });
expect(result.target).toBe('stalwart');
expect(result.buildId).toBe('bid-1');
expect(result.zipPath).toBe(join(root, STALWART_ZIP_NAME));
for (const file of ['index.html', 'config.json', 'policy.json', 'manifest.json', 'LITE-README.md', 'lite-build.json']) expect(existsSync(join(out, file)), file).toBe(true);
for (const file of ['404.html', '404', '_redirects', '_headers', 'nginx.conf.example', 'Caddyfile.example', 'manifest.webmanifest']) expect(existsSync(join(out, file)), file).toBe(false);
expect(readFileSync(join(out, '_next', 'static', 'media', 'pdf.worker.min.abc.js'), 'utf8')).toBe('worker()');
expect(existsSync(join(out, 'en', 'mail', '__next.!KG1haW4p'))).toBe(false);
expect(existsSync(join(out, 'en', 'mail', '__next._tree.txt'))).toBe(true);
expect(findSegmentPrefetchDirs(out)).toEqual([]);
const entry = readFileSync(join(out, 'index.html'), 'utf8');
expect(entry).toContain('var BUILD_ID = "bid-1"');
expect(entry).toContain('var SHELLS = ["","calendar","contacts","files","login","mail","settings"]');
expect(JSON.parse(readFileSync(join(out, 'lite-build.json'), 'utf8'))).toMatchObject({ target: 'stalwart', buildId: 'bid-1', commit: 'abcdef1' });
expect(JSON.parse(readFileSync(join(out, 'config.json'), 'utf8')).allowCustomJmapEndpoint).toBe(false);
const names = listZipEntries(readFileSync(result.zipPath!)).map((e) => e.name);
expect(names).toContain('index.html');
expect(names).toContain('en/mail/index.html');
expect(names.some((n) => n.startsWith('out/'))).toBe(false);
const verdict = verifyExport({ root });
expect(verdict.target).toBe('stalwart');
expect(verdict.problems).toEqual([]);
expect(verdict.zipBytes).toBeGreaterThan(0);
// Size guard: above the safety margin fails.
expect(verifyExport({ root, safeBundleBytes: 10 }).problems.join('\n')).toMatch(/safety margin/);
// A text row holding an asset URL would never be rewritten.
writeFileSync(join(out, 'en', 'mail', 'index.txt'), '1:T8,"/_next/');
expect(verifyExport({ root }).problems.join('\n')).toMatch(/text rows 1 hold root-absolute URLs/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('refuses a baked base path and reports a missing zip', () => {
const root = makeRepo();
try {
makeExport(root);
expect(() => runPostbuild({ root, env: env({ LITE_TARGET: 'stalwart', NEXT_PUBLIC_BASE_PATH: '/webmail' }), log: () => {} })).toThrow(/unset NEXT_PUBLIC_BASE_PATH/);
expect(verifyExport({ root, target: 'stalwart' }).problems.join('\n')).toMatch(/missing bulwark-lite-stalwart\.zip/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('keeps the static target unchanged', () => {
const root = makeRepo();
try {
makeExport(root);
const result = runPostbuild({ root, env: env({}), log: () => {} });
expect(result.target).toBe('static');
expect(result.zipPath).toBeNull();
expect(existsSync(join(root, 'out', '_redirects'))).toBe(true);
expect(readFileSync(join(root, 'out', '404.html'), 'utf8')).toContain(LITE_PENDING_PATH_KEY);
// Static hosts keep the segment-prefetch trees untouched (2 locales x 6 surfaces).
expect(findSegmentPrefetchDirs(join(root, 'out'))).toHaveLength(12);
expect(existsSync(join(root, STALWART_ZIP_NAME))).toBe(false);
expect(verifyExport({ root }).problems).toEqual([]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});