Skip to content

Commit 8699ee8

Browse files
fix: update tests for lazy JWT validation and atomic rate limiter
- Make JWT secret validation lazy (not at module import time) - Update rate-limit test to mock $transaction and expect fail-closed - Set JWT_SECRET in vitest setup for all test environments - Extend CI JWT_SECRET to 44 chars (was 28, below 32-char minimum)
1 parent a1f9ad1 commit 8699ee8

4 files changed

Lines changed: 26 additions & 11 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
--health-retries 5
2828
2929
env:
30-
JWT_SECRET: ci-only-do-not-use-in-prod
30+
JWT_SECRET: ci-only-do-not-use-in-prod-abcdef1234567890
3131
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/interviewlab_test
3232
# Auth/API rate limits are tuned tight for production; the live-server
3333
# integration suite below makes far more requests per minute than a

__tests__/lib/rate-limit.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,20 @@ const findUnique = vi.fn();
44
const upsert = vi.fn();
55
const update = vi.fn();
66
const deleteMany = vi.fn();
7+
const transaction = vi.fn((cb: (tx: unknown) => Promise<unknown>) =>
8+
cb({
9+
rateLimitEntry: {
10+
findUnique: (...args: unknown[]) => findUnique(...args),
11+
upsert: (...args: unknown[]) => upsert(...args),
12+
update: (...args: unknown[]) => update(...args),
13+
deleteMany: (...args: unknown[]) => deleteMany(...args),
14+
},
15+
})
16+
);
717

818
vi.mock('@/lib/db', () => ({
919
db: {
20+
$transaction: (...args: unknown[]) => transaction(...args),
1021
rateLimitEntry: {
1122
findUnique: (...args: unknown[]) => findUnique(...args),
1223
upsert: (...args: unknown[]) => upsert(...args),
@@ -24,6 +35,7 @@ describe('checkRateLimit', () => {
2435
upsert.mockReset();
2536
update.mockReset();
2637
deleteMany.mockReset();
38+
transaction.mockClear();
2739
});
2840

2941
it('allows the first request for a new key and creates an entry with count 1', async () => {
@@ -86,12 +98,12 @@ describe('checkRateLimit', () => {
8698
expect(findUnique).toHaveBeenCalledWith({ where: { key: 'auth-register:1.2.3.4' } });
8799
});
88100

89-
it('fails open (allows the request) if the database throws', async () => {
101+
it('fails closed (denies the request) if the database throws', async () => {
90102
findUnique.mockRejectedValue(new Error('connection lost'));
91103

92104
const result = await checkRateLimit('1.2.3.4', 'auth-login', 10, 60_000);
93105

94-
expect(result).toEqual({ allowed: true, remaining: 10 });
106+
expect(result).toEqual({ allowed: false, remaining: 0 });
95107
});
96108
});
97109

__tests__/setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
process.env.JWT_SECRET = "ci-test-secret-that-is-at-least-32-chars-long";
12
import '@testing-library/jest-dom';
23

34
// Only set up browser mocks in jsdom environment

src/lib/session.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import { SignJWT, jwtVerify } from 'jose';
1313
import { cookies } from 'next/headers';
1414
import { NextRequest, NextResponse } from 'next/server';
1515

16-
// JWT_SECRET must be configured with at least 32 characters
17-
const rawSecret = process.env.JWT_SECRET;
18-
if (!rawSecret || rawSecret.length < 32) {
19-
throw new Error("JWT_SECRET must be configured with at least 32 characters");
16+
// Validate JWT_SECRET lazily (not at module import time) so tests can set it
17+
function getJwtSecret(): Uint8Array {
18+
const rawSecret = process.env.JWT_SECRET;
19+
if (!rawSecret || rawSecret.length < 32) {
20+
throw new Error("JWT_SECRET must be configured with at least 32 characters");
21+
}
22+
return new TextEncoder().encode(rawSecret);
2023
}
21-
const JWT_SECRET = new TextEncoder().encode(rawSecret);
2224

2325
const TOKEN_NAME = 'interviewlab_session';
2426
const TOKEN_MAX_AGE = 24 * 60 * 60; // 24 hours in seconds
@@ -41,7 +43,7 @@ export async function createSession(
4143
.setProtectedHeader({ alg: 'HS256' })
4244
.setIssuedAt()
4345
.setExpirationTime(`${TOKEN_MAX_AGE}s`)
44-
.sign(JWT_SECRET);
46+
.sign(getJwtSecret());
4547

4648
const cookieOptions = {
4749
name: TOKEN_NAME,
@@ -69,7 +71,7 @@ export async function verifySession(request: NextRequest): Promise<SessionPayloa
6971
const token = request.cookies.get(TOKEN_NAME)?.value;
7072
if (!token) return null;
7173

72-
const { payload } = await jwtVerify(token, JWT_SECRET);
74+
const { payload } = await jwtVerify(token, getJwtSecret());
7375

7476
return {
7577
sub: payload.sub as string,
@@ -87,7 +89,7 @@ export async function verifySession(request: NextRequest): Promise<SessionPayloa
8789
*/
8890
export async function verifyToken(token: string): Promise<SessionPayload | null> {
8991
try {
90-
const { payload } = await jwtVerify(token, JWT_SECRET);
92+
const { payload } = await jwtVerify(token, getJwtSecret());
9193
return {
9294
sub: payload.sub as string,
9395
email: payload.email as string,

0 commit comments

Comments
 (0)