test(routes): integration-grade route-test harness with real auth middleware (#3962)#3986
Conversation
…rness
Introduces src/server/test-helpers/routeTestApp.ts — a real-middleware
harness for route tests that replaces the monkey-patch pattern
(vi.mock('../../services/database.js', ...)).
Key design decisions:
- Uses the live DatabaseService singleton (already :memory: + migrations)
so authMiddleware sees the same data as the harness — a second
createTestDb() connection would be invisible to the middleware.
- Real express-session (MemoryStore) + real optionalAuth() + real
requirePermission() — the full auth/permission chain executes.
- Programmatic login via POST /__test__/login (no bcrypt) keeps tests fast.
- Per-source grants via db.auth.createPermission with sourceId so real
checkPermissionAsync enforces isolation.
- Unique per-call usernames (rt-admin-N, rt-limited-N) prevent collisions
in the file-persistent singleton DB.
- cleanup() removes permissions + sources in afterEach.
Part of remediation epic #3962, Phase 1 Task 1.3 WP-A.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…ness (WP-A)
Removes the vi.mock('../../services/database.js') block and the hand-rolled
checkPermissionAsync lambda that re-implemented per-source scoping logic in
the test itself. Replaces with createRouteTestApp() so the real
checkPermissionAsync + permissions.sourceId SQL enforces isolation.
Changes:
- Delete vi.mock of services/database.js and mockDb alias
- Replace createApp() + hand-rolled session middleware with harness
- beforeEach: seed real permission rows via harness.grant()
- afterEach: harness.cleanup() removes perms + sources
- Keep vi.mock of sourceManagerRegistry + meshtasticManager (non-DB, correct)
New assertions added:
- admin bypass: loginAs(admin) → 200 on both sources without explicit grants
- anonymous fallback: loginAs(null) → 403 (real findUserByUsernameAsync path)
- source isolation proven: limited user granted on sourceA gets 200/200 on A
and 403/403 on B, driven purely by real SQL rows (no mock lambda)
- channel filtering regression: same node seeded on both sources; user with
channel_0:viewOnMap only on sourceA sees node on A (len≥1), filtered on B
(len=0), driven by real getUserPermissionSetAsync per-source scoping
Fixes the gap where the previous mock passed while the real SQL was broken
(issue #3745 root cause).
Part of remediation epic #3962, Phase 1 Task 1.3 WP-A.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
Adds a 'Route Test Harness' subsection to the 'Test Mocking Pattern' section in CLAUDE.md explaining when and why to use the harness vs the legacy monkey-patch pattern, and pointing to sourceRoutes.permissions.test.ts as the canonical template. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
DatabaseService.ensureAnonymousUser() is a fire-and-forget async task launched from the constructor; readyResolve() fires synchronously before it completes. Under vitest singleFork+isolate a new module instance (and therefore a new :memory: SQLite) is created for each file, so the constructor re-runs and the first beforeEach may reach getUserByUsername before ensureAnonymousUser commits the row. Add a 2-second polling loop (25 ms intervals) after waitForReady() so the harness waits for the async seeding to land before asserting the anonymous row exists. Resolves the intermittent race that caused all channelDatabaseRoutes + waypoints tests to fail in the combined 3-file run.
…sages withSourceScope() now throws when given undefined (introduced in commit 64081b6 — "withSourceScope fails closed"). getMessages() and getMessagesByChannel() did not apply the ?? ALL_SOURCES guard that getMessagesAfterTimestamp() already carried, so a GET /v1/messages without a sourceId query-param produced an unhandled 500. Add the ?? ALL_SOURCES fallback to both call sites; the comment "intentional cross-source when sourceId omitted" matches the pattern already established by the existing guard on the third path. Exposed by the real-harness conversion of messages.permissions.test.ts.
…ess (WP-B)
Remove vi.mock('../../services/database.js') and replace with
createRouteTestApp(useOptionalAuth: false) + req.user injection
(mirrors the requireAPIToken pattern used in production).
vi.spyOn passthroughs on checkPermissionAsync and
getUserPermissionSetAsync record call arguments while the real SQL
logic runs — proving both the routing path taken (per-source vs global)
and the actual permission enforcement (no hand-rolled lambda).
Source-isolation assertion: limited user holds messages:read +
channel_0:read on rt-source-a only. GET ?sourceId=rt-source-b returns
200 with count=0 because every getAccessibleChannels call returns false
from real checkPermissionAsync — previously a () => sourceId==='rt-source-a'
mock would have hidden any regression (issue #3745).
Also revealed and fixed a production bug: getMessages() and
getMessagesByChannel() were missing the ?? ALL_SOURCES sentinel, causing
a 500 on requests without a sourceId param (see companion fix commit).
…eware harness (WP-B)
Remove vi.mock('../../services/database.js') and vi.mock('../auth/authMiddleware.js')
blocks. Real requireAuth() reads session.userId set by harness.loginAs();
real checkPermissionAsync enforces the permission gate against live DB rows.
Non-DB service mocks kept: channelDecryptionService and
retroactiveDecryptionService avoid file/network I/O in tests. Data repo
methods (getAllAsync, getByIdAsync, etc.) are spied on per-test to
control returned rows while auth still runs against the real DB.
Source-isolation assertion: channel_database is a global-by-design
resource (no sourceId column). The isolation proof here is therefore
grant/no-grant: user WITH global channel_database:read gets 200;
user WITHOUT gets 403 from real checkPermissionAsync (no NULL-sourceId
row → false). Previously mockResolvedValue(false) could mask an
implementation regression.
11 tests across 4 describe blocks covering read/write permission
filtering, per-entry PSK masking, admin bypass, and the isolated
source-isolation pair.
Remove vi.mock('../../services/database.js'). Real requirePermission()
calls checkPermissionAsync against live DB rows seeded per-test.
Non-DB mocks kept: sourceManagerRegistry (avoids TCP) and waypointService
(avoids file I/O). databaseService.waypoints.getAsync is spied on in
DELETE tests where no rows are seeded.
Source-isolation assertion: limited user holds waypoints:read only on
rt-source-a. GET rt-source-a → 200; GET rt-source-b → 403 from real
checkPermissionAsync (no row for source-b).
Write-success tests use harness.admin (real admin bypass in
requirePermission) rather than stacking a second grant() call on the
same limited user, which would violate the UNIQUE constraint on
(userId, resource, sourceId) and raise a DB error.
Unauthenticated test updated from expected 401 to 403: the real
anonymous user exists in the DB, so findUserByUsernameAsync('anonymous')
succeeds and requirePermission falls through to a false check (no grant),
returning 403 rather than the mock-induced 401 of the old test.
16 tests across 4 describe blocks covering GET/POST/PATCH/DELETE
with permission, admin-bypass, source-isolation, and input-validation
scenarios.
|
Claude finished @Yeraze's task —— View job PR Review
This is a well-designed, high-value change. The harness correctly identifies and fixes a real testing anti-pattern. A few observations below, ranging from significant to minor.
|
| session({ | ||
| secret: 'route-test', | ||
| resave: false, | ||
| saveUninitialized: false, | ||
| cookie: { secure: false }, | ||
| // Cookie name must match requireAuth()'s cookie sniff (authMiddleware.ts L232) | ||
| // so requireAuth-guarded routes receive the session correctly. | ||
| name: 'meshmonitor.sid', | ||
| }) |
| session({ | ||
| secret: 'route-test', | ||
| resave: false, | ||
| saveUninitialized: false, | ||
| cookie: { secure: false }, | ||
| // Cookie name must match requireAuth()'s cookie sniff (authMiddleware.ts L232) | ||
| // so requireAuth-guarded routes receive the session correctly. | ||
| name: 'meshmonitor.sid', | ||
| }) |
| app.post('/__test__/login', (req, res) => { | ||
| req.session.userId = req.body.userId ?? undefined; | ||
| req.session.save(() => res.status(204).end()); | ||
| }); |
|
CodeQL triage note (orchestrator): the three alerts (#159 clear-text cookie, #160 missing CSRF, #161 session not rotated on login) are all on |
Summary
Task 1.3 of the remediation plan (#3962, Phase 1).
Route tests previously monkey-patched the permission layer (
(databaseService as any).checkPermissionAsync = vi.fn()...), so the real middleware chain was never exercised. This adds the shared fixture and converts 4 representative files as the template:src/server/test-helpers/routeTestApp.ts—createRouteTestApp({ mount })boots a real Express app with realexpress-session(MemoryStore), realoptionalAuth()/requireAuth/requirePermission, against the live in-memory-SQLitedatabaseServicesingleton (full migration chain — the vitest env already points the singleton at:memory:). Returns seededadmin/limited/anonymoususers, two sources,loginAs()(session minted via a test-only login route — no bcrypt), per-sourcegrant(), andcleanup().sourceRoutes.permissions(14 tests),v1/messages.permissions(3),channelDatabaseRoutes.permissions(11),waypoints(16). Each keeps its original scenarios and gains genuine source-isolation assertions — granted on source A → 200, denied on source B → 403/filtered — driven by real permission rows and realgetUserPermissionSetAsyncscoping, not mock lambdas.The conversion immediately paid for itself: exercising the real route code surfaced a production bug —
GET /api/v1/messageswithout asourceIdparam 500'd against the Task-1.1 fail-closed guard (two call sites inv1/messages.tsmissing the?? ALL_SOURCESdisposition their sibling line already had). Fixed here with the intentional-cross-source comment.Also includes a harness-robustness fix: poll for the fire-and-forget
ensureAnonymousUser()seeding afterwaitForReady()(constructor race under fresh per-file module instances).Verification (independent orchestrator run)
npm run typecheck: clean.npm run typecheck:tests: 283 (== baseline, no increase).success: true)..prepare/.exec/.queryraw SQL and zero monkey-patched permission fakes.Refs #3962
🤖 Generated with Claude Code
https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n