import path from 'node:path'; import http from 'node:http'; import { readFileSync } from 'node:fs'; import express from 'express'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { _captureLogger } from '../../src/core/logger.js'; const { accessMock } = vi.hoisted(() => ({ accessMock: vi.fn(), })); vi.mock('fs/promises', () => ({ default: { access: accessMock }, access: accessMock, })); import { registerWebUI, resolveWebDistDir, landingPageHtml, SPA_FALLBACK_REGEX, staticCacheControlSetHeaders, } from '../../src/server/api.js'; type MockRoute = { method: string; path: string | RegExp; handler: Function[] }; type MockApp = { use: ReturnType; get: ReturnType; _routes: MockRoute[]; }; const createMockApp = (): MockApp => { const _routes: MockRoute[] = []; return { use: vi.fn(), get: vi.fn((p: string | RegExp, ...h: Function[]) => _routes.push({ method: 'get', path: p, handler: h }), ), _routes, }; }; const invokeHandler = async (app: MockApp, method: string, reqPath: string) => { for (const route of app._routes) { if (route.method !== method) continue; if (route.path instanceof RegExp) { if (!route.path.test(reqPath)) continue; } else { if (route.path !== reqPath) continue; } const res: any = { sendFile: vi.fn(), type: vi.fn().mockReturnThis(), send: vi.fn().mockReturnThis(), setHeader: vi.fn(), }; await route.handler[0]({ path: reqPath } as any, res, vi.fn()); return res; } return null; }; describe('landingPageHtml', () => { const html = landingPageHtml(); it('contains void background colour from gitnexus-web design tokens', () => { expect(html).toContain('#06060a'); }); it('contains surface card colour from gitnexus-web design tokens', () => { expect(html).toContain('#101018'); }); it('contains accent colour from gitnexus-web design tokens', () => { expect(html).toContain('#7c3aed'); }); it('uses Outfit font with system-ui fallback', () => { expect(html).toContain('Outfit'); expect(html).toContain('system-ui'); }); it('contains the build command in a terminal-style block', () => { expect(html).toContain('cd gitnexus-web'); expect(html).toContain('npm run build'); }); it('contains the Vercel link with safe external attributes', () => { expect(html).toContain('https://gitnexus.vercel.app'); expect(html).toContain('target="_blank"'); expect(html).toContain('rel="noopener noreferrer"'); }); it('contains the Web UI not found message', () => { expect(html).toContain('Web UI not found'); }); }); describe('SPA fallback regex', () => { it('allows root path', () => { expect(SPA_FALLBACK_REGEX.test('/')).toBe(true); }); it('allows SPA routes', () => { expect(SPA_FALLBACK_REGEX.test('/processes')).toBe(true); expect(SPA_FALLBACK_REGEX.test('/settings')).toBe(true); expect(SPA_FALLBACK_REGEX.test('/clusters')).toBe(true); }); it('excludes /api paths', () => { expect(SPA_FALLBACK_REGEX.test('/api')).toBe(false); expect(SPA_FALLBACK_REGEX.test('/api/')).toBe(false); expect(SPA_FALLBACK_REGEX.test('/api/info')).toBe(false); expect(SPA_FALLBACK_REGEX.test('/api/does-not-exist')).toBe(false); }); it('excludes asset-like paths with file extensions', () => { expect(SPA_FALLBACK_REGEX.test('/assets/missing.js')).toBe(false); expect(SPA_FALLBACK_REGEX.test('/assets/missing.css')).toBe(false); expect(SPA_FALLBACK_REGEX.test('/favicon.ico')).toBe(false); expect(SPA_FALLBACK_REGEX.test('/assets/font.woff2')).toBe(false); expect(SPA_FALLBACK_REGEX.test('/static/app.map')).toBe(false); expect(SPA_FALLBACK_REGEX.test('/images/logo.png')).toBe(false); }); it('allows SPA routes with dots not at the end', () => { expect(SPA_FALLBACK_REGEX.test('/v1.0/api')).toBe(true); expect(SPA_FALLBACK_REGEX.test('/docs/v2.0/guide')).toBe(true); }); it('excludes paths ending in dot-plus-extension regardless of content before it', () => { expect(SPA_FALLBACK_REGEX.test('/user@example.com')).toBe(false); }); }); describe('registerWebUI', () => { it('registers express.static and SPA fallback when staticDir provided', () => { const app = createMockApp(); registerWebUI(app as any, '/some/dir'); expect(app.use).toHaveBeenCalledTimes(1); expect(app.get).toHaveBeenCalledTimes(1); const [regex] = app.get.mock.calls[0] as [RegExp, ...Function[]]; expect(regex.source).toBe(SPA_FALLBACK_REGEX.source); }); it('registers landing page route when staticDir is null', () => { const app = createMockApp(); registerWebUI(app as any, null); expect(app.use).not.toHaveBeenCalled(); expect(app.get).toHaveBeenCalledTimes(1); const [path] = app.get.mock.calls[0] as [string, ...Function[]]; expect(path).toBe('/'); }); it('landing page handler returns styled HTML', async () => { const app = createMockApp(); registerWebUI(app as any, null); const res = await invokeHandler(app, 'get', '/'); expect(res.type).toHaveBeenCalledWith('html'); expect(res.send).toHaveBeenCalledWith(expect.stringContaining('Web UI not found')); expect(res.send).toHaveBeenCalledWith(expect.stringContaining('#06060a')); expect(res.send).toHaveBeenCalledWith(expect.stringContaining('#7c3aed')); }); it('Cache-Control setHeaders sets no-cache for HTML, immutable for assets', () => { const captureHeaders = (filePath: string) => { const headers: Record = {}; const res = { setHeader: (k: string, v: string) => { headers[k] = v; }, }; staticCacheControlSetHeaders(res as express.Response, filePath); return headers; }; expect(captureHeaders('index.html')).toEqual({ 'Cache-Control': 'no-cache' }); expect(captureHeaders('app.js')).toEqual({ 'Cache-Control': 'public, max-age=31536000, immutable', }); expect(captureHeaders('style.css')).toEqual({ 'Cache-Control': 'public, max-age=31536000, immutable', }); }); }); describe('resolveWebDistDir', () => { beforeEach(() => { vi.clearAllMocks(); }); it('returns primary dir when index.html exists', async () => { accessMock.mockImplementation(async (p: string) => { if (p.includes('primary')) return undefined; throw Object.assign(new Error('not found'), { code: 'ENOENT' }); }); const result = await resolveWebDistDir('/primary', '/fallback'); expect(result).toBe('/primary'); }); it('returns fallback dir when primary missing', async () => { accessMock.mockImplementation(async (p: string) => { if (p.includes('fallback')) return undefined; throw Object.assign(new Error('not found'), { code: 'ENOENT' }); }); const result = await resolveWebDistDir('/primary', '/fallback'); expect(result).toBe('/fallback'); }); it('returns null when both dirs missing', async () => { accessMock.mockImplementation(async () => { throw Object.assign(new Error('not found'), { code: 'ENOENT' }); }); const result = await resolveWebDistDir('/primary', '/fallback'); expect(result).toBeNull(); }); it('warns on non-ENOENT errors but continues', async () => { const cap = _captureLogger(); accessMock.mockImplementation(async (p: string) => { if (p.includes('primary')) throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); if (p.includes('fallback')) return undefined; throw Object.assign(new Error('not found'), { code: 'ENOENT' }); }); const result = await resolveWebDistDir('/primary', '/fallback'); expect(result).toBe('/fallback'); expect( cap .records() .some( (r) => String(r.msg ?? '').includes('could not access web UI dir /primary') && r.err === 'permission denied', ), ).toBe(true); cap.restore(); }); it('prefers GITNEXUS_WEB_DIST env var when set', async () => { const original = process.env.GITNEXUS_WEB_DIST; process.env.GITNEXUS_WEB_DIST = '/env/dist'; try { accessMock.mockImplementation(async (p: string) => { const normalized = p.split(path.sep).join('/'); if (normalized.includes('/env/dist')) return undefined; throw Object.assign(new Error('not found'), { code: 'ENOENT' }); }); const result = await resolveWebDistDir('/primary', '/fallback'); expect(result).toBe('/env/dist'); } finally { if (original === undefined) { delete process.env.GITNEXUS_WEB_DIST; } else { process.env.GITNEXUS_WEB_DIST = original; } } }); it('falls back to primary when GITNEXUS_WEB_DIST dir missing', async () => { const original = process.env.GITNEXUS_WEB_DIST; process.env.GITNEXUS_WEB_DIST = '/env/dist'; try { accessMock.mockImplementation(async (p: string) => { const normalized = p.split(path.sep).join('/'); if (normalized.includes('/env/dist')) throw Object.assign(new Error('not found'), { code: 'ENOENT' }); if (normalized.includes('/primary')) return undefined; throw Object.assign(new Error('not found'), { code: 'ENOENT' }); }); const result = await resolveWebDistDir('/primary', '/fallback'); expect(result).toBe('/primary'); } finally { if (original === undefined) { delete process.env.GITNEXUS_WEB_DIST; } else { process.env.GITNEXUS_WEB_DIST = original; } } }); }); describe('Real Express dispatch — API and asset isolation', () => { const makeRequest = (app: express.Express, method: string, url: string): Promise => { return new Promise((resolve) => { const server = app.listen(0, () => { const opts = { hostname: 'localhost', port: (server.address() as any).port, method, path: url, }; const req = http.request(opts, (res) => { server.close(); resolve(res.statusCode ?? 0); }); req.on('error', () => { server.close(); resolve(0); }); req.end(); }); }); }; it('GET /api/does-not-exist returns 404, not SPA HTML', async () => { const app = express(); app.get('/api/info', (_req, res) => res.json({ ok: true })); registerWebUI(app, '/nonexistent'); const status = await makeRequest(app, 'GET', '/api/does-not-exist'); expect(status).toBe(404); }); it('GET /assets/missing.js returns 404, not SPA HTML', async () => { const app = express(); app.get('/api/info', (_req, res) => res.json({ ok: true })); registerWebUI(app, '/nonexistent'); const status = await makeRequest(app, 'GET', '/assets/missing.js'); expect(status).toBe(404); }); it('GET / returns the landing page when no web build exists', async () => { const app = express(); registerWebUI(app, null); const status = await makeRequest(app, 'GET', '/'); expect(status).toBe(200); }); it('does not register a legacy "*" OPTIONS route (Express 5 startup crash regression guard)', async () => { // The original PR #1747 startup crash was `app.options('*', ...)` throwing // under Express 5's stricter path parser. The fix on main is to NOT register // any explicit OPTIONS route — cors() handles preflights automatically and // the Access-Control-Allow-Private-Network header is set by global middleware // before cors. This regression guard fails loudly if someone re-adds a legacy // wildcard route to api.ts. const apiSource = readFileSync( path.join(__dirname, '..', '..', 'src', 'server', 'api.ts'), 'utf-8', ); expect(apiSource).not.toMatch(/app\.options\(\s*['"`]\*['"`]/); }); });