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).
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user