/** * Shared universal-deps Proxy for tests that load real route modules with stub * dependencies. Any property access returns a sensible value: * - asyncHandler (the most common trap): pass-through returning its argument * so `router.get('/path', asyncHandler(realHandler))` resolves to * `router.get('/path', realHandler)` and Express sees a real handler * - Other functions: noopFn returning undefined when called * - Objects: recursive proxy * * Used by: * - depth2-routes-smoke.test.js (verifies every depth-2 route module loads) * - public-routes-drift.test.js (walks aggregator routers via Express stack) */ const noopFn = () => undefined; const passThrough = (x) => x; // Logger-shaped noop: matches the real Logger's surface (error/warn/info/debug), // so factories that do `log.error('tag', 'msg', meta)` or `(ctx.log || console).error(...)` // don't blow up when run with stub deps. A bare `() => undefined` would throw because // `noopFn.error` is undefined. const loggerStub = { error: noopFn, warn: noopFn, info: noopFn, debug: noopFn, audit: noopFn }; const handler = { get(target, prop, receiver) { if (prop === 'asyncHandler') { // asyncHandler is special — it must accept a handler function and return // a wrapped handler function. Return a pass-through that wraps nothing. // This is the most common trap: `router.get('/path', asyncHandler(realHandler))` // resolves to `router.get('/path', realHandler)` and Express sees a real handler. return (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } if (prop === Symbol.toPrimitive) return undefined; if (prop === 'then') return undefined; // don't make the proxy thenable if (prop in target) return target[prop]; // Functions and methods — return noopFn that returns undefined when called if (typeof target[prop] === 'function') return target[prop]; return noopFn; }, // Object.assign / spread / Object.keys on the proxy only sees the target's // OWN enumerable keys. Without these traps, aggregator factories that copy // ctx into a subCtx via `Object.assign({}, ctx, { helpers })` lose the // proxy's magic (e.g. asyncHandler), and downstream factories fail with // 'asyncHandler is not a function'. Expose all seed keys as own enumerable // so they survive the copy. ownKeys(target) { return Reflect.ownKeys(target); }, getOwnPropertyDescriptor(target, prop) { if (prop in target) return Object.getOwnPropertyDescriptor(target, prop); return undefined; } }; // Seed the proxy with a few known-shape fields so modules that destructure // them get the right type. Anything else falls back to noopFn via the handler. const seed = { fetchT: async () => ({ ok: true, status: 200, json: async () => ({}) }), // asyncHandler is special — see handler.get below. We also seed it as an // own enumerable property so Object.assign({}, ctx, { helpers }) copies it // through (the proxy's ownKeys trap only exposes own keys, so anything not // in the seed is invisible to spread/assign even though the get trap returns it). asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next), servicesStateManager: { read: async () => [], write: async () => {}, update: async () => [] }, siteConfig: { tld: '.home', dnsServers: {}, pylon: null }, buildServiceUrl: (id) => `https://${id}.sami}`, logError: async () => undefined, // Logger-shaped stub (not a bare noopFn) so `(ctx.log || console).error(...)` // and `log.error('tag','msg',meta)` calls don't throw. See loggerStub above. log: loggerStub, errorResponse: noopFn, healthChecker: { getCurrentStatus: () => ({}), getServiceStats: () => null, configureService: noopFn, removeService: noopFn, getOpenIncidents: () => [], getIncidentHistory: () => [] }, authManager: {}, credentialManager: { store: async () => undefined, retrieve: async () => null, diagnose: async () => ({ status: 'missing' }), rotateKey: async () => undefined }, totpConfig: { isSetUp: false, enabled: false, sessionDuration: 'never', getConfig: () => ({}), saveConfig: async () => undefined }, saveTotpConfig: async () => undefined, session: { create: async () => ({}), invalidate: async () => undefined, isValid: () => true }, licenseManager: { requirePremium: () => (req, res, next) => next(), hasFeature: () => false }, getServiceById: () => null, getAppSession: () => null, appSessionCache: { get: () => null, set: noopFn }, renewCSRFToken: () => 'csrf-token', createCache: () => ({ get: () => null, set: noopFn }), CACHE_CONFIGS: {}, docker: {}, notification: { send: noopFn }, buildDomain: (s) => s, caddy: {}, addServiceToConfig: async () => undefined, APP_TEMPLATES: {}, DOCKER: {}, REGEX: {}, TIMEOUTS: {}, APP: {}, PLEX: {}, LIMITS: {}, SESSION_TTL: 86400, buildMediaAuth: () => ({}), CADDY: {}, DEFAULT_DNS_PORT: '5380', isValidPort: () => true, exists: async () => true, validateURL: () => true, validateToken: () => true, validateAndLogConfig: () => ({}), validateConfig: () => ({ valid: true, errors: [], warnings: [] }), ValidationError: class extends Error {}, AuthenticationError: class extends Error {}, ForbiddenError: class extends Error {}, NotFoundError: class extends Error {}, ok: noopFn, successMessage: noopFn, validationError: noopFn, notFound: noopFn, error: noopFn, platformPaths: {}, RECIPE_TEMPLATES: {}, RECIPE_CATEGORIES: [], ARR_SERVICES: {}, APP_PORTS: {}, cryptoUtils: { encrypt: async (x) => x, decrypt: async (x) => x }, // Path-like strings for routes that do `path.dirname(SERVICES_FILE)` etc // before the factory body runs (e.g. routes/config/backup.js). Bare noopFn // would throw 'path argument must be of type string. Received function'. SERVICES_FILE: '/tmp/dashcaddy/services.json', CONFIG_FILE: '/tmp/dashcaddy/config.json', TOTP_CONFIG_FILE: '/tmp/dashcaddy/totp.json', TAILSCALE_CONFIG_FILE: '/tmp/dashcaddy/tailscale.json', NOTIFICATIONS_FILE: '/tmp/dashcaddy/notifications.json', // Aggregator convenience: factories pass ctx.X into sub-router mounts; // some sub-routers destructure these by name. Seed-as-own-property so // Object.assign({}, ctx, { helpers }) copies them through. loadSiteConfig: async () => ({}), loadNotificationConfig: async () => ({}), configStateManager: { read: async () => ({}), write: async () => undefined, update: async () => undefined }, readConfig: async () => ({}), saveConfig: async () => undefined, helpers: {}, safeErrorMessage: (e) => (e && e.message) || 'Unknown error' }; module.exports = { universalDeps: new Proxy(seed, handler), noopFn, passThrough };