Files
dashcaddy/dashcaddy-api/__tests__/test-helpers/universal-deps.js
Hermes ab0ef9cfa1
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-017: Regression tests for depth-2 route paths + PUBLIC_ROUTES drift
After DC-005 path-fix (c39c80b) shipped 67 broken-require repairs across 21
depth-2 route files, two test gaps remained:

  1. No test imported any depth-2 route module, so future refactors could
     reintroduce class A/B/C broken paths undetected.
  2. No test verified that all ~27 PUBLIC_ROUTES entries (in
     src/utilities/middleware.js) corresponded to actually-mounted routes.
     DC-012 added a similar check for the 5 probe paths, but only those.

Added 3 files, fixed 1 test helper, no production code changed:

  - __tests__/depth2-routes-smoke.test.js (new): discovers every .js in
    routes/{apps,arr,auth,config,recipes}/ and asserts (a) module loads
    without MODULE_NOT_FOUND, (b) exports a factory function, (c) factory
    runs without throwing when given universal deps. Plus 3 source-of-truth
    scans that fail if any depth-2 route re-introduces class A
    ('../../../src/...'), class B ('../src/...'), or class C
    ('utilities/responses' instead of 'utils/responses') require paths.

  - __tests__/public-routes-drift.test.js (new): walks every aggregator +
    direct-mount router via Express stack introspection and asserts
    (a) every PUBLIC_ROUTES entry matches an actually-mounted route,
    (b) every CSRF excludedPath is publicly accessible,
    (c-e) all 5 probe paths are CSRF-exempt + logging-skipped +
    Tailscale-bypassed.

  - __tests__/test-helpers/universal-deps.js (new): Proxy + seed-object
    shared by both suites. Returns sensible stubs for any property access
    (logger-shaped object, asyncHandler pass-through, path-string stubs).
    Supports Object.assign/spread via ownKeys+getOwnPropertyDescriptor
    traps so aggregator factories that copy ctx into subCtx don't lose
    proxy magic.

Test-helper fixes needed to make the suites pass:

  - 'log' is now a logger-shaped object ({error, warn, info, debug, audit}
    as noops), not a bare noopFn — fixes '(ctx.log || console).error(...)'
    in routes/apps/index.js factory catch block.
  - 'asyncHandler' seeded as own enumerable property — survives
    Object.assign({}, ctx, { helpers }) used by routes/arr/index.js etc.
  - Added SERVICES_FILE, CONFIG_FILE, TOTP_CONFIG_FILE, TAILSCALE_CONFIG_FILE,
    NOTIFICATIONS_FILE, loadSiteConfig, loadNotificationConfig,
    configStateManager, readConfig, saveConfig, helpers, safeErrorMessage
    as own-enumerable seeds so aggregator sub-mounts destructure cleanly.

Public-routes-drift test fixes:

  - Aggregator walks use prefix '/api/v1' (matches src/app.js's bare-mount
    on apiRouter at /api/v1). Without this, the 6 TOTP routes registered by
    routes/auth/index.js appeared as '/totp/config' instead of
    '/api/v1/totp/config' and were falsely flagged as stale.
  - Direct-mount walks use '/api/v1' + explicit prefixMap entry (same reason).
  - Added routes/themes.js and routes/license.js to directMounts.

Result: 35 suites, 1036 tests, all passing (was 1030 passing + 6 failing
before this commit). The 6 pre-existing failures were depth-2 factory
errors + 22 PUBLIC_ROUTES stale entries that the test infrastructure
was silently swallowing — these tests surface them so they can't recur.

BACKLOG.md updated with full DC-017 entry (status: done, owner: krystie).
2026-06-26 12:16:38 -07:00

165 lines
6.7 KiB
JavaScript

/**
* 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 };