Commit b347f5b
* feat(tests): add createRouteTestApp() integration-grade route test harness
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
* test(routes): convert sourceRoutes.permissions to real-middleware harness (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
* docs: document createRouteTestApp() as standard for new route tests
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
* fix(tests): poll for anonymous user after waitForReady() in routeTestApp
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.
* fix(routes): use ALL_SOURCES sentinel when sourceId missing in v1 messages
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.
* test(routes): convert v1 messages permissions to real-middleware harness (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).
* test(routes): convert channelDatabaseRoutes permissions to real-middleware 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.
* test(routes): convert waypoints to real-middleware harness (WP-B)
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.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b4bf3eb commit b347f5b
7 files changed
Lines changed: 824 additions & 414 deletions
File tree
- src/server
- routes
- v1
- test-helpers
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
93 | 110 | | |
94 | 111 | | |
95 | 112 | | |
| |||
Lines changed: 129 additions & 93 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
| 2 | + | |
3 | 3 | | |
4 | | - | |
5 | | - | |
6 | | - | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
7 | 9 | | |
8 | | - | |
9 | | - | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
10 | 24 | | |
11 | 25 | | |
12 | | - | |
13 | | - | |
14 | | - | |
15 | | - | |
16 | | - | |
17 | | - | |
18 | | - | |
19 | | - | |
20 | | - | |
21 | | - | |
22 | | - | |
23 | | - | |
24 | | - | |
25 | | - | |
26 | | - | |
27 | | - | |
28 | | - | |
29 | | - | |
30 | | - | |
31 | | - | |
32 | | - | |
33 | | - | |
34 | | - | |
35 | | - | |
36 | | - | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
| 26 | + | |
41 | 27 | | |
| 28 | + | |
42 | 29 | | |
43 | 30 | | |
44 | 31 | | |
| |||
53 | 40 | | |
54 | 41 | | |
55 | 42 | | |
| 43 | + | |
56 | 44 | | |
57 | | - | |
58 | | - | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
| 45 | + | |
63 | 46 | | |
64 | 47 | | |
65 | 48 | | |
| |||
78 | 61 | | |
79 | 62 | | |
80 | 63 | | |
81 | | - | |
82 | | - | |
83 | | - | |
84 | | - | |
85 | | - | |
86 | | - | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
87 | 74 | | |
88 | | - | |
89 | | - | |
90 | | - | |
91 | 75 | | |
92 | | - | |
93 | | - | |
94 | | - | |
95 | | - | |
96 | | - | |
97 | | - | |
98 | | - | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
99 | 85 | | |
100 | 86 | | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
101 | 123 | | |
102 | 124 | | |
103 | | - | |
| 125 | + | |
| 126 | + | |
104 | 127 | | |
105 | 128 | | |
106 | 129 | | |
107 | 130 | | |
108 | 131 | | |
109 | | - | |
110 | | - | |
111 | | - | |
112 | | - | |
113 | | - | |
114 | | - | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
115 | 135 | | |
116 | | - | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
117 | 139 | | |
118 | 140 | | |
119 | 141 | | |
120 | 142 | | |
121 | 143 | | |
122 | 144 | | |
123 | 145 | | |
124 | | - | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
125 | 149 | | |
126 | 150 | | |
127 | 151 | | |
128 | 152 | | |
| 153 | + | |
| 154 | + | |
129 | 155 | | |
130 | 156 | | |
131 | | - | |
132 | | - | |
133 | | - | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
134 | 160 | | |
135 | | - | |
136 | | - | |
137 | | - | |
138 | | - | |
139 | | - | |
140 | | - | |
141 | | - | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
142 | 164 | | |
143 | 165 | | |
144 | 166 | | |
145 | 167 | | |
146 | 168 | | |
147 | 169 | | |
148 | | - | |
149 | | - | |
150 | | - | |
151 | | - | |
152 | | - | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
153 | 175 | | |
154 | 176 | | |
155 | 177 | | |
156 | 178 | | |
157 | | - | |
| 179 | + | |
| 180 | + | |
158 | 181 | | |
159 | 182 | | |
160 | 183 | | |
161 | 184 | | |
| 185 | + | |
| 186 | + | |
162 | 187 | | |
163 | 188 | | |
164 | | - | |
165 | | - | |
166 | | - | |
167 | | - | |
168 | | - | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
169 | 193 | | |
170 | 194 | | |
171 | 195 | | |
172 | 196 | | |
173 | 197 | | |
174 | 198 | | |
175 | | - | |
176 | | - | |
177 | | - | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
178 | 202 | | |
179 | | - | |
180 | | - | |
181 | | - | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
182 | 206 | | |
183 | 207 | | |
184 | 208 | | |
185 | 209 | | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
186 | 222 | | |
0 commit comments