Skip to content

Commit 33cd8be

Browse files
Merge pull request #162 from Jayking40/#110-Auth--requireAuth-integration-coverage-for
test(auth): protected route integration coverage
2 parents 4b3caaf + cac0355 commit 33cd8be

File tree

1 file changed

+264
-0
lines changed

1 file changed

+264
-0
lines changed

tests/integration/protected.test.ts

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,29 @@ import express from 'express';
44
import jwt from 'jsonwebtoken';
55
import { createTestDb } from '../helpers/db.js';
66
import { signTestToken, signExpiredToken, TEST_JWT_SECRET } from '../helpers/jwt.js';
7+
import { createApp } from '../../src/app.js';
8+
import { InMemoryUsageEventsRepository } from '../../src/repositories/usageEventsRepository.js';
9+
import { InMemoryVaultRepository } from '../../src/repositories/vaultRepository.js';
10+
import type { Developer } from '../../src/db/schema.js';
11+
import type { DeveloperRepository } from '../../src/repositories/developerRepository.js';
12+
import type { ApiRepository, ApiListFilters } from '../../src/repositories/apiRepository.js';
13+
14+
jest.mock('uuid', () => ({ v4: () => 'mock-uuid-1234' }));
15+
16+
// Mock better-sqlite3 to avoid native binding requirement in test env
17+
jest.mock('better-sqlite3', () => {
18+
return class MockDatabase {
19+
prepare() { return { get: () => null }; }
20+
exec() { }
21+
close() { }
22+
};
23+
});
24+
25+
// Mock the userRepository to avoid the Prisma import chain
26+
// (userRepository → lib/prisma → generated/prisma/client which doesn't exist in test env)
27+
jest.mock('../../src/repositories/userRepository', () => ({
28+
findUsers: jest.fn().mockResolvedValue({ users: [], total: 0 }),
29+
}));
730

831
function buildProtectedApp(pool: any) {
932
const app = express();
@@ -109,3 +132,244 @@ describe('GET /api/usage - JWT protected', () => {
109132
expect(res.body.error).toBe('No token provided');
110133
});
111134
});
135+
136+
// ---------------------------------------------------------------------------
137+
// requireAuth middleware – integration coverage against real createApp routes
138+
// ---------------------------------------------------------------------------
139+
140+
const testDeveloper: Developer = {
141+
id: 7,
142+
user_id: 'user-42',
143+
name: 'Integration Tester',
144+
website: null,
145+
description: null,
146+
category: null,
147+
created_at: new Date(0),
148+
updated_at: new Date(0),
149+
};
150+
151+
const stubDeveloperRepository: DeveloperRepository = {
152+
async findByUserId(userId: string) {
153+
return userId === testDeveloper.user_id ? testDeveloper : undefined;
154+
},
155+
};
156+
157+
class StubApiRepository implements ApiRepository {
158+
async listByDeveloper(_developerId: number, _filters?: ApiListFilters) {
159+
return [];
160+
}
161+
async findById() {
162+
return null;
163+
}
164+
async getEndpoints() {
165+
return [];
166+
}
167+
}
168+
169+
/**
170+
* Build a createApp instance with lightweight in-memory stubs so that
171+
* route handlers can execute without hitting a real database.
172+
*/
173+
function buildRealApp() {
174+
const vaultRepo = new InMemoryVaultRepository();
175+
return createApp({
176+
usageEventsRepository: new InMemoryUsageEventsRepository(),
177+
vaultRepository: vaultRepo,
178+
developerRepository: stubDeveloperRepository,
179+
apiRepository: new StubApiRepository(),
180+
findDeveloperByUserId: async (id) => stubDeveloperRepository.findByUserId(id),
181+
createApiWithEndpoints: async (input) => ({
182+
id: 1,
183+
developer_id: input.developer_id,
184+
name: input.name,
185+
description: input.description ?? null,
186+
base_url: input.base_url,
187+
logo_url: null,
188+
category: input.category ?? null,
189+
status: input.status ?? 'draft',
190+
created_at: new Date(),
191+
updated_at: new Date(),
192+
endpoints: [],
193+
}),
194+
});
195+
}
196+
197+
/** Standard assertion for an unauthenticated response from the errorHandler */
198+
function expectUnauthorized(res: request.Response) {
199+
expect(res.status).toBe(401);
200+
expect(res.body).toHaveProperty('error');
201+
expect(res.body.error).toBe('Unauthorized');
202+
expect(res.body.code).toBe('UNAUTHORIZED');
203+
}
204+
205+
// Collect every protected endpoint so we can run the same failure-mode matrix
206+
// against each one without duplicating boilerplate.
207+
const protectedEndpoints: Array<{
208+
method: 'get' | 'post' | 'delete';
209+
path: string;
210+
body?: Record<string, unknown>;
211+
}> = [
212+
{ method: 'get', path: '/api/developers/apis' },
213+
{ method: 'get', path: '/api/developers/analytics' },
214+
{ method: 'post', path: '/api/vault/deposit/prepare', body: { amount_usdc: '10.00' } },
215+
{ method: 'get', path: '/api/vault/balance' },
216+
{ method: 'delete', path: '/api/keys/nonexistent-id' },
217+
{ method: 'post', path: '/api/developers/apis', body: { name: 'Test', base_url: 'https://t.co', endpoints: [] } },
218+
];
219+
220+
describe('requireAuth – rejects unauthenticated requests on all protected routes', () => {
221+
let app: express.Express;
222+
223+
beforeAll(() => {
224+
app = buildRealApp();
225+
});
226+
227+
describe.each(protectedEndpoints)(
228+
'$method $path',
229+
({ method, path, body }) => {
230+
it('returns 401 when no auth headers are present', async () => {
231+
const req = request(app)[method](path);
232+
if (body) req.send(body);
233+
const res = await req;
234+
expectUnauthorized(res);
235+
});
236+
237+
it('returns 401 when Bearer token is empty', async () => {
238+
const req = request(app)[method](path).set('Authorization', 'Bearer ');
239+
if (body) req.send(body);
240+
const res = await req;
241+
expectUnauthorized(res);
242+
});
243+
244+
it('returns 401 when Bearer token is whitespace-only', async () => {
245+
const req = request(app)[method](path).set('Authorization', 'Bearer ');
246+
if (body) req.send(body);
247+
const res = await req;
248+
expectUnauthorized(res);
249+
});
250+
251+
it('returns 401 with non-Bearer scheme (Basic)', async () => {
252+
const req = request(app)[method](path).set('Authorization', 'Basic dXNlcjpwYXNz');
253+
if (body) req.send(body);
254+
const res = await req;
255+
expectUnauthorized(res);
256+
});
257+
},
258+
);
259+
});
260+
261+
describe('requireAuth – accepts valid credentials on protected routes', () => {
262+
let app: express.Express;
263+
264+
beforeAll(() => {
265+
app = buildRealApp();
266+
});
267+
268+
it('authenticates via Bearer token on GET /api/developers/apis', async () => {
269+
const res = await request(app)
270+
.get('/api/developers/apis')
271+
.set('Authorization', 'Bearer user-42');
272+
273+
// Auth passes; the route itself may return 200 (empty list) or 404 depending on developer lookup
274+
expect(res.status).not.toBe(401);
275+
});
276+
277+
it('authenticates via x-user-id header on GET /api/developers/apis', async () => {
278+
const res = await request(app)
279+
.get('/api/developers/apis')
280+
.set('x-user-id', 'user-42');
281+
282+
expect(res.status).not.toBe(401);
283+
});
284+
285+
it('authenticates via Bearer token on GET /api/developers/analytics', async () => {
286+
const res = await request(app)
287+
.get('/api/developers/analytics?from=2026-01-01&to=2026-01-31')
288+
.set('Authorization', 'Bearer user-42');
289+
290+
expect(res.status).not.toBe(401);
291+
});
292+
293+
it('authenticates via x-user-id header on GET /api/developers/analytics', async () => {
294+
const res = await request(app)
295+
.get('/api/developers/analytics?from=2026-01-01&to=2026-01-31')
296+
.set('x-user-id', 'user-42');
297+
298+
expect(res.status).not.toBe(401);
299+
});
300+
301+
it('authenticates via Bearer token on POST /api/vault/deposit/prepare', async () => {
302+
const res = await request(app)
303+
.post('/api/vault/deposit/prepare')
304+
.set('Authorization', 'Bearer user-42')
305+
.send({ amount_usdc: '10.00' });
306+
307+
// 404 (no vault) is acceptable — not 401
308+
expect(res.status).not.toBe(401);
309+
});
310+
311+
it('authenticates via x-user-id header on GET /api/vault/balance', async () => {
312+
const res = await request(app)
313+
.get('/api/vault/balance')
314+
.set('x-user-id', 'user-42');
315+
316+
// 404 (no vault) is acceptable — not 401
317+
expect(res.status).not.toBe(401);
318+
});
319+
320+
it('authenticates via Bearer token on DELETE /api/keys/:id', async () => {
321+
const res = await request(app)
322+
.delete('/api/keys/nonexistent-id')
323+
.set('Authorization', 'Bearer user-42');
324+
325+
// 204 (not_found falls through to 204 in current impl) — not 401
326+
expect(res.status).not.toBe(401);
327+
});
328+
329+
it('authenticates via x-user-id header on POST /api/developers/apis', async () => {
330+
const res = await request(app)
331+
.post('/api/developers/apis')
332+
.set('x-user-id', 'user-42')
333+
.send({ name: 'My API', base_url: 'https://example.com', endpoints: [] });
334+
335+
expect(res.status).not.toBe(401);
336+
});
337+
});
338+
339+
describe('requireAuth – error body consistency', () => {
340+
let app: express.Express;
341+
342+
beforeAll(() => {
343+
app = buildRealApp();
344+
});
345+
346+
it('returns JSON content-type for 401 responses', async () => {
347+
const res = await request(app).get('/api/developers/apis');
348+
349+
expect(res.status).toBe(401);
350+
expect(res.headers['content-type']).toMatch(/application\/json/);
351+
});
352+
353+
it('does not leak stack traces or internal details in 401 body', async () => {
354+
const res = await request(app).get('/api/vault/balance');
355+
356+
expect(res.status).toBe(401);
357+
expect(res.body).not.toHaveProperty('stack');
358+
expect(res.body).not.toHaveProperty('statusCode');
359+
// Only expected keys
360+
const keys = Object.keys(res.body);
361+
expect(keys).toEqual(expect.arrayContaining(['error', 'code']));
362+
expect(keys.length).toBe(2);
363+
});
364+
365+
it('produces identical error shape across different protected routes', async () => {
366+
const res1 = await request(app).get('/api/developers/apis');
367+
const res2 = await request(app).post('/api/vault/deposit/prepare').send({});
368+
const res3 = await request(app).delete('/api/keys/abc');
369+
370+
for (const res of [res1, res2, res3]) {
371+
expect(res.status).toBe(401);
372+
expect(res.body).toEqual({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
373+
}
374+
});
375+
});

0 commit comments

Comments
 (0)