Skip to content

Commit fb55e1a

Browse files
committed
Added Jest testing, ESLint static analysis, and GitHub Actions CI pipeline
1 parent c06c6b1 commit fb55e1a

File tree

10 files changed

+10580
-1540
lines changed

10 files changed

+10580
-1540
lines changed

.eslintrc.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"env": {
3+
"browser": true,
4+
"es2021": true,
5+
"node": true,
6+
"jest": true
7+
},
8+
"extends": [
9+
"eslint:recommended",
10+
"plugin:react/recommended"
11+
],
12+
"parserOptions": {
13+
"ecmaFeatures": {
14+
"jsx": true
15+
},
16+
"ecmaVersion": "latest",
17+
"sourceType": "module"
18+
},
19+
"plugins": ["react"],
20+
"rules": {
21+
"react/react-in-jsx-scope": "off",
22+
"react/prop-types": "off",
23+
"react/no-unescaped-entities": "off",
24+
"react/no-unknown-property": "warn",
25+
"no-unused-vars": "warn",
26+
"no-console": "off"
27+
},
28+
"settings": {
29+
"react": {
30+
"version": "detect"
31+
}
32+
}
33+
}

.github/workflows/ci.yml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
lint:
11+
name: Lint
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: Checkout code
15+
uses: actions/checkout@v4
16+
17+
- name: Setup Node.js
18+
uses: actions/setup-node@v4
19+
with:
20+
node-version: '18'
21+
cache: 'npm'
22+
23+
- name: Install dependencies
24+
run: npm ci
25+
26+
- name: Run ESLint
27+
run: npm run lint
28+
29+
test:
30+
name: Test
31+
runs-on: ubuntu-latest
32+
steps:
33+
- name: Checkout code
34+
uses: actions/checkout@v4
35+
36+
- name: Setup Node.js
37+
uses: actions/setup-node@v4
38+
with:
39+
node-version: '18'
40+
cache: 'npm'
41+
42+
- name: Install dependencies
43+
run: npm ci
44+
45+
- name: Run tests with coverage
46+
run: npm run test:coverage
47+
48+
- name: Upload coverage report
49+
uses: actions/upload-artifact@v4
50+
with:
51+
name: coverage-report
52+
path: coverage/
53+
retention-days: 7
54+
55+
build:
56+
name: Build
57+
runs-on: ubuntu-latest
58+
needs: [lint, test]
59+
steps:
60+
- name: Checkout code
61+
uses: actions/checkout@v4
62+
63+
- name: Setup Node.js
64+
uses: actions/setup-node@v4
65+
with:
66+
node-version: '18'
67+
cache: 'npm'
68+
69+
- name: Install dependencies
70+
run: npm ci
71+
72+
- name: Build application
73+
run: npm run build

__tests__/api/auth.test.js

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import getUserFromReq from '@/pages/api/auth';
2+
import { withClient } from '@/pages/api/db';
3+
import jwt from 'jsonwebtoken';
4+
5+
// Mock dependencies
6+
jest.mock('@/pages/api/db', () => ({
7+
__esModule: true,
8+
default: {},
9+
withClient: jest.fn(),
10+
}));
11+
12+
jest.mock('jsonwebtoken', () => ({
13+
verify: jest.fn(),
14+
}));
15+
16+
describe('getUserFromReq (auth)', () => {
17+
beforeEach(() => {
18+
jest.clearAllMocks();
19+
process.env.JWT_SECRET = 'test-secret';
20+
});
21+
22+
describe('Token extraction', () => {
23+
it('extracts token from Authorization header', async () => {
24+
jwt.verify.mockReturnValue({ userId: 1 });
25+
withClient.mockImplementation(async (callback) => {
26+
const mockClient = {
27+
query: jest.fn().mockResolvedValue({
28+
rows: [{ id: 1, username: 'testuser' }],
29+
}),
30+
};
31+
return callback(mockClient);
32+
});
33+
34+
const req = {
35+
headers: {
36+
authorization: 'Bearer valid-token',
37+
},
38+
};
39+
40+
const user = await getUserFromReq(req);
41+
42+
expect(jwt.verify).toHaveBeenCalledWith('valid-token', 'test-secret');
43+
expect(user).toEqual({ id: 1, username: 'testuser' });
44+
});
45+
46+
it('extracts token from cookie', async () => {
47+
jwt.verify.mockReturnValue({ userId: 1 });
48+
withClient.mockImplementation(async (callback) => {
49+
const mockClient = {
50+
query: jest.fn().mockResolvedValue({
51+
rows: [{ id: 1, username: 'testuser' }],
52+
}),
53+
};
54+
return callback(mockClient);
55+
});
56+
57+
const req = {
58+
headers: {
59+
cookie: 'token=cookie-token; other=value',
60+
},
61+
};
62+
63+
const user = await getUserFromReq(req);
64+
65+
expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret');
66+
expect(user).toEqual({ id: 1, username: 'testuser' });
67+
});
68+
69+
it('prefers Authorization header over cookie', async () => {
70+
jwt.verify.mockReturnValue({ userId: 1 });
71+
withClient.mockImplementation(async (callback) => {
72+
const mockClient = {
73+
query: jest.fn().mockResolvedValue({
74+
rows: [{ id: 1, username: 'testuser' }],
75+
}),
76+
};
77+
return callback(mockClient);
78+
});
79+
80+
const req = {
81+
headers: {
82+
authorization: 'Bearer header-token',
83+
cookie: 'token=cookie-token',
84+
},
85+
};
86+
87+
await getUserFromReq(req);
88+
89+
expect(jwt.verify).toHaveBeenCalledWith('header-token', 'test-secret');
90+
});
91+
});
92+
93+
describe('Authentication errors', () => {
94+
it('throws 401 when no token provided', async () => {
95+
const req = {
96+
headers: {},
97+
};
98+
99+
await expect(getUserFromReq(req)).rejects.toMatchObject({
100+
message: 'Not authenticated',
101+
status: 401,
102+
});
103+
});
104+
105+
it('throws 401 when Authorization header is malformed', async () => {
106+
const req = {
107+
headers: {
108+
authorization: 'InvalidFormat token',
109+
},
110+
};
111+
112+
await expect(getUserFromReq(req)).rejects.toMatchObject({
113+
message: 'Not authenticated',
114+
status: 401,
115+
});
116+
});
117+
118+
it('throws 401 when token is invalid', async () => {
119+
jwt.verify.mockImplementation(() => {
120+
throw new Error('invalid token');
121+
});
122+
123+
const req = {
124+
headers: {
125+
authorization: 'Bearer invalid-token',
126+
},
127+
};
128+
129+
await expect(getUserFromReq(req)).rejects.toMatchObject({
130+
message: 'Invalid or expired token',
131+
status: 401,
132+
});
133+
});
134+
135+
it('throws 401 when token is expired', async () => {
136+
jwt.verify.mockImplementation(() => {
137+
const error = new Error('jwt expired');
138+
error.name = 'TokenExpiredError';
139+
throw error;
140+
});
141+
142+
const req = {
143+
headers: {
144+
authorization: 'Bearer expired-token',
145+
},
146+
};
147+
148+
await expect(getUserFromReq(req)).rejects.toMatchObject({
149+
message: 'Invalid or expired token',
150+
status: 401,
151+
});
152+
});
153+
154+
it('throws 401 when user not found in database', async () => {
155+
jwt.verify.mockReturnValue({ userId: 999 });
156+
withClient.mockImplementation(async (callback) => {
157+
const mockClient = {
158+
query: jest.fn().mockResolvedValue({ rows: [] }),
159+
};
160+
return callback(mockClient);
161+
});
162+
163+
const req = {
164+
headers: {
165+
authorization: 'Bearer valid-token',
166+
},
167+
};
168+
169+
await expect(getUserFromReq(req)).rejects.toMatchObject({
170+
message: 'User not found',
171+
status: 401,
172+
});
173+
});
174+
});
175+
176+
describe('Database errors', () => {
177+
it('throws 500 on database connection error', async () => {
178+
jwt.verify.mockReturnValue({ userId: 1 });
179+
withClient.mockRejectedValue(new Error('Connection refused'));
180+
181+
const req = {
182+
headers: {
183+
authorization: 'Bearer valid-token',
184+
},
185+
};
186+
187+
await expect(getUserFromReq(req)).rejects.toMatchObject({
188+
message: 'Authentication error',
189+
status: 500,
190+
});
191+
});
192+
});
193+
});

0 commit comments

Comments
 (0)