diff --git a/src/backend/controllers/static/StaticAssetsController.test.ts b/src/backend/controllers/static/StaticAssetsController.test.ts new file mode 100644 index 000000000..295de0db8 --- /dev/null +++ b/src/backend/controllers/static/StaticAssetsController.test.ts @@ -0,0 +1,313 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { Request, Response } from 'express'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PuterRouter } from '../../core/http/PuterRouter.js'; +import type { RouteDescriptor } from '../../core/http/types.js'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; +import type { IConfig } from '../../types.js'; +import { StaticAssetsController } from './StaticAssetsController.js'; + +// ── Test harness ──────────────────────────────────────────────────── +// +// StaticAssetsController is config-gated: each branch +// (`client_libs_root`, `puterjs_root`, `gui_assets_root`, +// `builtin_apps`) is enabled by setting that root + having the right +// files on disk. Tests boot one real PuterServer to wire up the +// clients/stores/services that controller construction expects, then +// instantiate StaticAssetsController with per-test config overrides +// and inspect the routes it registers onto a fresh PuterRouter. +// +// Real temp directories are seeded with the files each branch +// expects, so the controller's `fs.existsSync` calls see real state +// instead of a mocked module. + +let server: PuterServer; +let tmpRoots: string[] = []; + +beforeAll(async () => { + server = await setupTestServer(); +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +beforeEach(() => { + tmpRoots = []; +}); + +afterEach(() => { + for (const root of tmpRoots) { + try { + rmSync(root, { recursive: true, force: true }); + } catch { + // Best-effort cleanup. + } + } +}); + +const makeTempDir = (): string => { + const dir = mkdtempSync(path.join(tmpdir(), 'puter-static-test-')); + tmpRoots.push(dir); + return dir; +}; + +const buildController = (configOverrides: Partial = {}) => { + // Reuse the live wired clients/stores/services/drivers from the + // booted test server — only the config differs per test. + const controller = new StaticAssetsController( + { ...configOverrides } as IConfig, + server.clients, + server.stores, + server.services, + server.drivers, + ); + const router = new PuterRouter(); + controller.registerRoutes(router); + return router; +}; + +const findRoute = ( + router: PuterRouter, + method: string, + routePath: string, +): RouteDescriptor | undefined => + router.routes.find((r) => r.method === method && r.path === routePath); + +const findUseRoute = ( + router: PuterRouter, + routePath: string, +): RouteDescriptor | undefined => + router.routes.find((r) => r.method === 'use' && r.path === routePath); + +const callGetHandler = async ( + router: PuterRouter, + routePath: string, +): Promise<{ filename?: string; root?: string }> => { + const route = findRoute(router, 'get', routePath); + if (!route) throw new Error(`No GET ${routePath} registered`); + let captured: { filename?: string; root?: string } = {}; + const req = {} as Request; + const res = { + sendFile: vi.fn((filename: string, opts: { root: string }) => { + captured = { filename, root: opts.root }; + }), + } as unknown as Response; + await route.handler(req, res, () => { + throw new Error('handler called next() unexpectedly'); + }); + return captured; +}; + +// ── client_libs_root ──────────────────────────────────────────────── + +describe('StaticAssetsController client_libs_root', () => { + it('registers /puter.js/v1, /v2, /putility/v1 on the right subdomains', async () => { + const root = makeTempDir(); + const router = buildController({ client_libs_root: root }); + + const v1Wild = findRoute(router, 'get', '/puter.js/v1'); + const v2Wild = findRoute(router, 'get', '/puter.js/v2'); + const v1Js = findRoute(router, 'get', '/v1'); + const v2Js = findRoute(router, 'get', '/v2'); + const putilityJs = findRoute(router, 'get', '/putility/v1'); + + // /puter.js/* routes are wildcard-subdomain (any host). + expect(v1Wild?.options.subdomain).toBe('*'); + expect(v2Wild?.options.subdomain).toBe('*'); + // Bare-version routes live on the `js` subdomain. + expect(v1Js?.options.subdomain).toBe('js'); + expect(v2Js?.options.subdomain).toBe('js'); + expect(putilityJs?.options.subdomain).toBe('js'); + + // The handler should hand off the right relative file path. + const sent = await callGetHandler(router, '/puter.js/v1'); + expect(sent).toEqual({ filename: 'puter.js/v1.js', root }); + + const sent2 = await callGetHandler(router, '/v2'); + expect(sent2).toEqual({ filename: 'puter.js/v2.js', root }); + + const sentPutility = await callGetHandler(router, '/putility/v1'); + expect(sentPutility).toEqual({ filename: 'putility.js/v1.js', root }); + }); + + it('does not register the client_libs routes when the root is unset', () => { + const router = buildController({}); + expect(findRoute(router, 'get', '/puter.js/v1')).toBeUndefined(); + expect(findRoute(router, 'get', '/v1')).toBeUndefined(); + expect(findRoute(router, 'get', '/putility/v1')).toBeUndefined(); + }); +}); + +// ── puterjs_root ──────────────────────────────────────────────────── + +describe('StaticAssetsController puterjs_root', () => { + it('serves puter.js when puter.dev.js is missing (OSS-built repo)', async () => { + const root = makeTempDir(); + // OSS repo ships only puter.js — no puter.dev.js artifact. + writeFileSync(path.join(root, 'puter.js'), '/* mock */'); + const router = buildController({ puterjs_root: root }); + + // `/sdk/puter.dev.js` aliases to puter.js in this configuration. + const aliased = await callGetHandler(router, '/sdk/puter.dev.js'); + expect(aliased.filename).toBe('puter.js'); + expect(aliased.root).toBe(root); + + // /sdk static mount must be registered on the empty subdomain. + const sdkUse = findUseRoute(router, '/sdk'); + expect(sdkUse?.options.subdomain).toBe(''); + + // Without client_libs_root, /puter.js/v1 / /v1 routes also point + // at puterjs_root and serve puter.js. + const v1 = await callGetHandler(router, '/puter.js/v1'); + expect(v1.filename).toBe('puter.js'); + expect(v1.root).toBe(root); + const bareV1 = await callGetHandler(router, '/v1'); + expect(bareV1.filename).toBe('puter.js'); + }); + + it('serves puter.dev.js when present (dev webpack build)', async () => { + const root = makeTempDir(); + writeFileSync(path.join(root, 'puter.dev.js'), '/* dev */'); + // puter.js may also exist; presence of dev wins. + writeFileSync(path.join(root, 'puter.js'), '/* prod */'); + const router = buildController({ puterjs_root: root }); + + const v1 = await callGetHandler(router, '/puter.js/v1'); + expect(v1.filename).toBe('puter.dev.js'); + + // The /sdk/puter.dev.js alias is NOT registered in this case + // because express.static below it serves the file natively. + const aliasRoute = findRoute(router, 'get', '/sdk/puter.dev.js'); + expect(aliasRoute).toBeUndefined(); + }); + + it('skips bare /v1, /v2, /puter.js/* when client_libs_root is also set', async () => { + const libRoot = makeTempDir(); + const sdkRoot = makeTempDir(); + writeFileSync(path.join(sdkRoot, 'puter.js'), '/* mock */'); + const router = buildController({ + client_libs_root: libRoot, + puterjs_root: sdkRoot, + }); + // The /puter.js/v1 handler routes through client_libs_root + // (registered first; PuterRouter is order-preserving). Verify + // by confirming the file the handler resolves comes from libRoot. + const sent = await callGetHandler(router, '/puter.js/v1'); + expect(sent.root).toBe(libRoot); + // The /sdk mount should still be present from puterjs_root. + expect(findUseRoute(router, '/sdk')).toBeDefined(); + }); + + it('does not register /sdk when puterjs_root is unset', () => { + const router = buildController({}); + expect(findUseRoute(router, '/sdk')).toBeUndefined(); + }); +}); + +// ── gui_assets_root ───────────────────────────────────────────────── + +describe('StaticAssetsController gui_assets_root', () => { + it('mounts /dist and /src on the empty subdomain', () => { + const root = makeTempDir(); + // /assets requires public/ to also exist; create only dist+src here. + mkdirSync(path.join(root, 'dist')); + mkdirSync(path.join(root, 'src')); + const router = buildController({ gui_assets_root: root }); + + const distMount = findUseRoute(router, '/dist'); + const srcMount = findUseRoute(router, '/src'); + expect(distMount?.options.subdomain).toBe(''); + expect(srcMount?.options.subdomain).toBe(''); + }); + + it('mounts /assets only when public/ exists', () => { + const root = makeTempDir(); + mkdirSync(path.join(root, 'dist')); + mkdirSync(path.join(root, 'src')); + // No public/ → /assets should NOT be registered. + const router = buildController({ gui_assets_root: root }); + expect(findUseRoute(router, '/assets')).toBeUndefined(); + }); + + it('mounts /assets when public/ exists', () => { + const root = makeTempDir(); + mkdirSync(path.join(root, 'dist')); + mkdirSync(path.join(root, 'src')); + mkdirSync(path.join(root, 'public')); + const router = buildController({ gui_assets_root: root }); + const assetsMount = findUseRoute(router, '/assets'); + expect(assetsMount?.options.subdomain).toBe(''); + }); +}); + +// ── builtin_apps ──────────────────────────────────────────────────── + +describe('StaticAssetsController builtin_apps', () => { + it('registers a /builtin/ mount per existing dir', () => { + const editorRoot = makeTempDir(); + const browserRoot = makeTempDir(); + const router = buildController({ + builtin_apps: { + editor: editorRoot, + browser: browserRoot, + } as unknown as IConfig['builtin_apps'], + }); + + expect(findUseRoute(router, '/builtin/editor')).toBeDefined(); + expect(findUseRoute(router, '/builtin/browser')).toBeDefined(); + }); + + it('skips entries whose dirPath is empty / nonexistent', () => { + const realRoot = makeTempDir(); + const router = buildController({ + builtin_apps: { + editor: realRoot, + missing: '/nonexistent/path', + empty: '', + } as unknown as IConfig['builtin_apps'], + }); + + expect(findUseRoute(router, '/builtin/editor')).toBeDefined(); + expect(findUseRoute(router, '/builtin/missing')).toBeUndefined(); + expect(findUseRoute(router, '/builtin/empty')).toBeUndefined(); + }); + + it('does not register any /builtin mounts when builtin_apps is undefined', () => { + const router = buildController({}); + for (const route of router.routes) { + expect(route.path).not.toMatch(/^\/builtin\//); + } + }); +}); + +// ── No-config (everything off) ────────────────────────────────────── + +describe('StaticAssetsController with no roots configured', () => { + it('registers no routes', () => { + const router = buildController({}); + expect(router.routes).toHaveLength(0); + }); +});