Skip to content

Commit 71f9002

Browse files
committed
test: add auth integration tests
1 parent 649ed24 commit 71f9002

File tree

2 files changed

+279
-1
lines changed

2 files changed

+279
-1
lines changed

server/server.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ if (require.main === module) {
839839
});
840840
}
841841

842-
module.exports = { app, server };
842+
module.exports = { app, server, sessions };
843843

844844

845845
// ========================

tests/integration/auth.test.js

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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(/instbyte_auth/)
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

Comments
 (0)