1+ /**
2+ * tests/integration/auth.test.js
3+ *
4+ * Tests authentication — login, logout, protected routes, public routes.
5+ *
6+ * How auth works in Instbyte:
7+ * - If config.auth.passphrase is empty, all routes are public (no auth)
8+ * - If a passphrase is set, POST /login validates it and sets a cookie
9+ * containing a random session token
10+ * - The sessions Map in server.js holds valid tokens
11+ * - requireAuth middleware checks the cookie against the sessions Map
12+ * - /login, /info, /health are always public even when auth is enabled
13+ *
14+ * Test strategy:
15+ * - We mutate config.auth.passphrase directly (it's the same object the
16+ * server uses) to enable/disable auth per test
17+ * - We inject tokens directly into the sessions Map to simulate a logged-in
18+ * user without going through the full login flow
19+ * - We always restore config.auth.passphrase to "" in afterEach so other
20+ * test files are not affected
21+ */
22+
23+ import { describe , it , expect , beforeEach , afterEach } from 'vitest'
24+ import request from 'supertest'
25+ import { createRequire } from 'module'
26+ import { setup , resetDb , getApp } from '../helpers/setup.js'
27+
28+ const require = createRequire ( import . meta. url )
29+
30+ setup ( )
31+
32+ // Grab the live config and sessions objects from the loaded modules.
33+ // These are the exact same references the server uses at runtime.
34+ let config
35+ let sessions
36+
37+ beforeEach ( async ( ) => {
38+ await resetDb ( )
39+ config = require ( '../../server/config.js' )
40+ sessions = require ( '../../server/server.js' ) . sessions
41+ // ensure auth is off before each test — tests that need it turn it on
42+ config . auth . passphrase = ''
43+ sessions . clear ( )
44+ } )
45+
46+ afterEach ( ( ) => {
47+ // always restore so other test files run without auth
48+ config . auth . passphrase = ''
49+ sessions . clear ( )
50+ } )
51+
52+ // ─────────────────────────────────────────────────────────────────────────────
53+ // No auth mode (default)
54+ // ─────────────────────────────────────────────────────────────────────────────
55+
56+ describe ( 'when no passphrase is set' , ( ) => {
57+ it ( 'allows access to protected routes without any credentials' , async ( ) => {
58+ const res = await request ( getApp ( ) ) . get ( '/channels' )
59+ expect ( res . status ) . toBe ( 200 )
60+ } )
61+
62+ it ( 'allows POST /text without credentials' , async ( ) => {
63+ const res = await request ( getApp ( ) )
64+ . post ( '/text' )
65+ . send ( { content : 'hello' , channel : 'general' , uploader : 'Alice' } )
66+ expect ( res . status ) . toBe ( 200 )
67+ } )
68+
69+ it ( '/info reports hasAuth false' , async ( ) => {
70+ const res = await request ( getApp ( ) ) . get ( '/info' )
71+ expect ( res . status ) . toBe ( 200 )
72+ expect ( res . body . hasAuth ) . toBe ( false )
73+ } )
74+ } )
75+
76+ // ─────────────────────────────────────────────────────────────────────────────
77+ // Auth enabled — public routes still accessible
78+ // ─────────────────────────────────────────────────────────────────────────────
79+
80+ describe ( 'when passphrase is set — public routes' , ( ) => {
81+ beforeEach ( ( ) => {
82+ config . auth . passphrase = 'secret123'
83+ } )
84+
85+ it ( '/info is accessible without credentials' , async ( ) => {
86+ const res = await request ( getApp ( ) ) . get ( '/info' )
87+ expect ( res . status ) . toBe ( 200 )
88+ expect ( res . body . hasAuth ) . toBe ( true )
89+ } )
90+
91+ it ( '/health is accessible without credentials' , async ( ) => {
92+ const res = await request ( getApp ( ) ) . get ( '/health' )
93+ expect ( res . status ) . toBe ( 200 )
94+ } )
95+
96+ it ( 'GET /login is accessible without credentials' , async ( ) => {
97+ const res = await request ( getApp ( ) ) . get ( '/login' )
98+ expect ( res . status ) . toBe ( 200 )
99+ } )
100+ } )
101+
102+ // ─────────────────────────────────────────────────────────────────────────────
103+ // Auth enabled — protected routes blocked
104+ // ─────────────────────────────────────────────────────────────────────────────
105+
106+ describe ( 'when passphrase is set — protected routes blocked' , ( ) => {
107+ beforeEach ( ( ) => {
108+ config . auth . passphrase = 'secret123'
109+ } )
110+
111+ it ( 'blocks GET /channels with JSON 401 for API requests' , async ( ) => {
112+ const res = await request ( getApp ( ) )
113+ . get ( '/channels' )
114+ . set ( 'Content-Type' , 'application/json' )
115+ expect ( res . status ) . toBe ( 401 )
116+ } )
117+
118+ it ( 'blocks POST /text with 401' , async ( ) => {
119+ const res = await request ( getApp ( ) )
120+ . post ( '/text' )
121+ . set ( 'Content-Type' , 'application/json' )
122+ . send ( { content : 'hello' , channel : 'general' , uploader : 'Alice' } )
123+ expect ( res . status ) . toBe ( 401 )
124+ } )
125+
126+ it ( 'blocks GET /items/:channel with 401' , async ( ) => {
127+ const res = await request ( getApp ( ) )
128+ . get ( '/items/general' )
129+ . set ( 'Content-Type' , 'application/json' )
130+ expect ( res . status ) . toBe ( 401 )
131+ } )
132+
133+ it ( 'blocks DELETE /item/:id with 401' , async ( ) => {
134+ const res = await request ( getApp ( ) )
135+ . delete ( '/item/1' )
136+ . set ( 'Content-Type' , 'application/json' )
137+ expect ( res . status ) . toBe ( 401 )
138+ } )
139+ } )
140+
141+ // ─────────────────────────────────────────────────────────────────────────────
142+ // POST /login
143+ // ─────────────────────────────────────────────────────────────────────────────
144+
145+ describe ( 'POST /login' , ( ) => {
146+ beforeEach ( ( ) => {
147+ config . auth . passphrase = 'secret123'
148+ } )
149+
150+ it ( 'returns 200 and ok:true with correct passphrase' , async ( ) => {
151+ const res = await request ( getApp ( ) )
152+ . post ( '/login' )
153+ . send ( { passphrase : 'secret123' } )
154+
155+ expect ( res . status ) . toBe ( 200 )
156+ expect ( res . body . ok ) . toBe ( true )
157+ } )
158+
159+ it ( 'sets a session cookie on successful login' , async ( ) => {
160+ const res = await request ( getApp ( ) )
161+ . post ( '/login' )
162+ . send ( { passphrase : 'secret123' } )
163+
164+ const cookie = res . headers [ 'set-cookie' ]
165+ expect ( cookie ) . toBeDefined ( )
166+ expect ( cookie [ 0 ] ) . toMatch ( / i n s t b y t e _ a u t h / )
167+ } )
168+
169+ it ( 'adds a token to the sessions Map on successful login' , async ( ) => {
170+ await request ( getApp ( ) )
171+ . post ( '/login' )
172+ . send ( { passphrase : 'secret123' } )
173+
174+ expect ( sessions . size ) . toBe ( 1 )
175+ } )
176+
177+ it ( 'returns 401 with wrong passphrase' , async ( ) => {
178+ const res = await request ( getApp ( ) )
179+ . post ( '/login' )
180+ . send ( { passphrase : 'wrongpassword' } )
181+
182+ expect ( res . status ) . toBe ( 401 )
183+ expect ( res . body . error ) . toBeDefined ( )
184+ } )
185+
186+ it ( 'does not add a token to sessions on failed login' , async ( ) => {
187+ await request ( getApp ( ) )
188+ . post ( '/login' )
189+ . send ( { passphrase : 'wrongpassword' } )
190+
191+ expect ( sessions . size ) . toBe ( 0 )
192+ } )
193+ } )
194+
195+ // ─────────────────────────────────────────────────────────────────────────────
196+ // Authenticated requests
197+ // ─────────────────────────────────────────────────────────────────────────────
198+
199+ describe ( 'authenticated requests' , ( ) => {
200+ beforeEach ( ( ) => {
201+ config . auth . passphrase = 'secret123'
202+ } )
203+
204+ it ( 'allows access to protected routes with a valid session cookie' , async ( ) => {
205+ // inject a token directly rather than going through login
206+ const token = 'test-token-abc123'
207+ sessions . set ( token , true )
208+
209+ const res = await request ( getApp ( ) )
210+ . get ( '/channels' )
211+ . set ( 'Cookie' , `instbyte_auth=${ token } ` )
212+
213+ expect ( res . status ) . toBe ( 200 )
214+ } )
215+
216+ it ( 'blocks access with an invalid session token' , async ( ) => {
217+ const res = await request ( getApp ( ) )
218+ . get ( '/channels' )
219+ . set ( 'Cookie' , 'instbyte_auth=not-a-valid-token' )
220+ . set ( 'Content-Type' , 'application/json' )
221+
222+ expect ( res . status ) . toBe ( 401 )
223+ } )
224+
225+ it ( 'full login flow grants access to protected routes' , async ( ) => {
226+ // login to get a real cookie
227+ const loginRes = await request ( getApp ( ) )
228+ . post ( '/login' )
229+ . send ( { passphrase : 'secret123' } )
230+
231+ const cookie = loginRes . headers [ 'set-cookie' ] [ 0 ] . split ( ';' ) [ 0 ]
232+
233+ // use that cookie to access a protected route
234+ const res = await request ( getApp ( ) )
235+ . get ( '/channels' )
236+ . set ( 'Cookie' , cookie )
237+
238+ expect ( res . status ) . toBe ( 200 )
239+ } )
240+ } )
241+
242+ // ─────────────────────────────────────────────────────────────────────────────
243+ // POST /logout
244+ // ─────────────────────────────────────────────────────────────────────────────
245+
246+ describe ( 'POST /logout' , ( ) => {
247+ beforeEach ( ( ) => {
248+ config . auth . passphrase = 'secret123'
249+ } )
250+
251+ it ( 'removes the token from the sessions Map' , async ( ) => {
252+ const token = 'logout-test-token'
253+ sessions . set ( token , true )
254+ expect ( sessions . size ) . toBe ( 1 )
255+
256+ await request ( getApp ( ) )
257+ . post ( '/logout' )
258+ . set ( 'Cookie' , `instbyte_auth=${ token } ` )
259+
260+ expect ( sessions . size ) . toBe ( 0 )
261+ } )
262+
263+ it ( 'after logout the token no longer grants access' , async ( ) => {
264+ const token = 'revoked-token'
265+ sessions . set ( token , true )
266+
267+ await request ( getApp ( ) )
268+ . post ( '/logout' )
269+ . set ( 'Cookie' , `instbyte_auth=${ token } ` )
270+
271+ const res = await request ( getApp ( ) )
272+ . get ( '/channels' )
273+ . set ( 'Cookie' , `instbyte_auth=${ token } ` )
274+ . set ( 'Content-Type' , 'application/json' )
275+
276+ expect ( res . status ) . toBe ( 401 )
277+ } )
278+ } )
0 commit comments