create-simo-api 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -2
- package/index.js +318 -0
- package/package.json +24 -4
- package/template/README.md +61 -0
- package/template/_env.example +15 -0
- package/template/_gitignore +4 -0
- package/template/app.js +29 -0
- package/template/config/db.js +6 -0
- package/template/config/env.js +27 -0
- package/template/config/permissions.js +15 -0
- package/template/controllers/authController.js +53 -0
- package/template/controllers/productController.js +34 -0
- package/template/controllers/userController.js +22 -0
- package/template/index.js +6 -0
- package/template/middlewares/authMiddleware.js +15 -0
- package/template/middlewares/errorMiddleware.js +33 -0
- package/template/middlewares/rateLimitMiddleware.js +14 -0
- package/template/middlewares/roleMiddleware.js +22 -0
- package/template/middlewares/validateMiddleware.js +18 -0
- package/template/migrations/1792673478177_create-users-table.sql +16 -0
- package/template/migrations/1792842956065_create-refresh-tokens-table.sql +17 -0
- package/template/migrations/1792842957555_create-products-table.sql +14 -0
- package/template/models/productModel.js +42 -0
- package/template/models/refreshTokenModel.js +25 -0
- package/template/models/userModel.js +39 -0
- package/template/package.json +33 -0
- package/template/routes/authRoutes.js +13 -0
- package/template/routes/index.js +15 -0
- package/template/routes/productRoutes.js +17 -0
- package/template/routes/userRoutes.js +15 -0
- package/template/seeds/seed.js +50 -0
- package/template/services/authService.js +94 -0
- package/template/services/productService.js +37 -0
- package/template/services/userService.js +24 -0
- package/template/tests/api.test.js +174 -0
- package/template/utils/AppError.js +10 -0
- package/template/utils/verifyToken.js +12 -0
- package/template/validations/authValidation.js +18 -0
- package/template/validations/productValidation.js +21 -0
- package/template/validations/userValidation.js +10 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import app from '../app.js';
|
|
5
|
+
import db from '../config/db.js';
|
|
6
|
+
|
|
7
|
+
const stamp = Date.now();
|
|
8
|
+
const userEmail = `test.user.${stamp}@test.com`;
|
|
9
|
+
const adminEmail = `test.admin.${stamp}@test.com`;
|
|
10
|
+
const password = 'secret123';
|
|
11
|
+
|
|
12
|
+
let userToken;
|
|
13
|
+
let adminToken;
|
|
14
|
+
let productId;
|
|
15
|
+
|
|
16
|
+
const login = (email) => request(app).post('/api/auth/login').send({ email, password });
|
|
17
|
+
|
|
18
|
+
const refreshCookie = (res) => res.headers['set-cookie'].find((cookie) => cookie.startsWith('refreshToken=')).split(';')[0];
|
|
19
|
+
|
|
20
|
+
before(async () => {
|
|
21
|
+
await request(app).post('/api/auth/register').send({ name: 'Test User', email: userEmail, password });
|
|
22
|
+
await request(app).post('/api/auth/register').send({ name: 'Test Admin', email: adminEmail, password });
|
|
23
|
+
await db.query("UPDATE users SET role = 'admin' WHERE email = $1", [adminEmail]);
|
|
24
|
+
|
|
25
|
+
userToken = (await login(userEmail)).body.accessToken;
|
|
26
|
+
adminToken = (await login(adminEmail)).body.accessToken;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
after(async () => {
|
|
30
|
+
await db.query('DELETE FROM users WHERE email LIKE $1', [`test.%.${stamp}@test.com`]);
|
|
31
|
+
if (productId) await db.query('DELETE FROM products WHERE id = $1', [productId]);
|
|
32
|
+
await db.end();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('Auth', () => {
|
|
36
|
+
it('register returns userId', async () => {
|
|
37
|
+
const email = `test.register.${stamp}@test.com`;
|
|
38
|
+
const res = await request(app).post('/api/auth/register').send({ name: 'Reg', email, password });
|
|
39
|
+
assert.equal(res.status, 201);
|
|
40
|
+
assert.equal(typeof res.body.userId, 'number');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('register rejects invalid body', async () => {
|
|
44
|
+
const res = await request(app).post('/api/auth/register').send({ email: 'abc', password: '1' });
|
|
45
|
+
assert.equal(res.status, 400);
|
|
46
|
+
assert.deepEqual(res.body.errors.map((e) => e.field).sort(), ['email', 'name', 'password']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('register rejects existing email (case insensitive)', async () => {
|
|
50
|
+
const res = await request(app).post('/api/auth/register').send({ name: 'Dup', email: userEmail.toUpperCase(), password });
|
|
51
|
+
assert.equal(res.status, 409);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('login rejects wrong password', async () => {
|
|
55
|
+
const res = await request(app).post('/api/auth/login').send({ email: userEmail, password: 'wrong-pass' });
|
|
56
|
+
assert.equal(res.status, 401);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('login returns access token and refresh cookie', async () => {
|
|
60
|
+
const res = await login(userEmail);
|
|
61
|
+
assert.equal(res.status, 200);
|
|
62
|
+
assert.ok(res.body.accessToken);
|
|
63
|
+
assert.ok(refreshCookie(res));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('refresh rotates the token and detects reuse', async () => {
|
|
67
|
+
const oldCookie = refreshCookie(await login(userEmail));
|
|
68
|
+
|
|
69
|
+
const first = await request(app).post('/api/auth/refresh').set('Cookie', oldCookie);
|
|
70
|
+
assert.equal(first.status, 200);
|
|
71
|
+
const newCookie = refreshCookie(first);
|
|
72
|
+
|
|
73
|
+
const reused = await request(app).post('/api/auth/refresh').set('Cookie', oldCookie);
|
|
74
|
+
assert.equal(reused.status, 401);
|
|
75
|
+
|
|
76
|
+
const afterReuse = await request(app).post('/api/auth/refresh').set('Cookie', newCookie);
|
|
77
|
+
assert.equal(afterReuse.status, 401);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps sessions on two devices', async () => {
|
|
81
|
+
const deviceA = refreshCookie(await login(userEmail));
|
|
82
|
+
const deviceB = refreshCookie(await login(userEmail));
|
|
83
|
+
|
|
84
|
+
assert.equal((await request(app).post('/api/auth/refresh').set('Cookie', deviceA)).status, 200);
|
|
85
|
+
assert.equal((await request(app).post('/api/auth/refresh').set('Cookie', deviceB)).status, 200);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('logout revokes the refresh token', async () => {
|
|
89
|
+
const cookie = refreshCookie(await login(userEmail));
|
|
90
|
+
assert.equal((await request(app).post('/api/auth/logout').set('Cookie', cookie)).status, 200);
|
|
91
|
+
assert.equal((await request(app).post('/api/auth/refresh').set('Cookie', cookie)).status, 401);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('Users', () => {
|
|
96
|
+
it('GET /me returns the connected user without password', async () => {
|
|
97
|
+
const res = await request(app).get('/api/user/me').set('Authorization', `Bearer ${userToken}`);
|
|
98
|
+
assert.equal(res.status, 200);
|
|
99
|
+
assert.equal(res.body.user.email, userEmail);
|
|
100
|
+
assert.equal(res.body.user.password, undefined);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('GET /me requires a token', async () => {
|
|
104
|
+
const res = await request(app).get('/api/user/me');
|
|
105
|
+
assert.equal(res.status, 401);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('getAll is admin only', async () => {
|
|
109
|
+
const asUser = await request(app).get('/api/user/getAll').set('Authorization', `Bearer ${userToken}`);
|
|
110
|
+
const asAdmin = await request(app).get('/api/user/getAll').set('Authorization', `Bearer ${adminToken}`);
|
|
111
|
+
assert.equal(asUser.status, 403);
|
|
112
|
+
assert.equal(asAdmin.status, 200);
|
|
113
|
+
assert.ok(asAdmin.body.users.every((user) => user.password === undefined));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('Products', () => {
|
|
118
|
+
it('user cannot create a product', async () => {
|
|
119
|
+
const res = await request(app).post('/api/product/create').set('Authorization', `Bearer ${userToken}`).send({ name: 'Nope', price: 10 });
|
|
120
|
+
assert.equal(res.status, 403);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('rejects invalid product', async () => {
|
|
124
|
+
const res = await request(app).post('/api/product/create').set('Authorization', `Bearer ${adminToken}`).send({ name: 'X', price: -5 });
|
|
125
|
+
assert.equal(res.status, 400);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('admin creates a product', async () => {
|
|
129
|
+
const res = await request(app).post('/api/product/create').set('Authorization', `Bearer ${adminToken}`).send({ name: 'Test Product', price: 99.5, stock: 3 });
|
|
130
|
+
assert.equal(res.status, 201);
|
|
131
|
+
productId = res.body.product.id;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('lists and gets products publicly', async () => {
|
|
135
|
+
const list = await request(app).get('/api/product/getAll');
|
|
136
|
+
assert.equal(list.status, 200);
|
|
137
|
+
assert.ok(list.body.products.some((product) => product.id === productId));
|
|
138
|
+
|
|
139
|
+
const one = await request(app).get(`/api/product/${productId}`);
|
|
140
|
+
assert.equal(one.status, 200);
|
|
141
|
+
assert.equal(one.body.product.name, 'Test Product');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('admin updates a product', async () => {
|
|
145
|
+
const res = await request(app).patch(`/api/product/update/${productId}`).set('Authorization', `Bearer ${adminToken}`).send({ stock: 10 });
|
|
146
|
+
assert.equal(res.status, 200);
|
|
147
|
+
assert.equal(res.body.product.stock, 10);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('rejects update with unknown field', async () => {
|
|
151
|
+
const res = await request(app).patch(`/api/product/update/${productId}`).set('Authorization', `Bearer ${adminToken}`).send({ hacked: true });
|
|
152
|
+
assert.equal(res.status, 400);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns 400 for invalid id and 404 for missing product', async () => {
|
|
156
|
+
assert.equal((await request(app).get('/api/product/abc')).status, 400);
|
|
157
|
+
assert.equal((await request(app).get('/api/product/999999999')).status, 404);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('admin deletes a product', async () => {
|
|
161
|
+
const res = await request(app).delete(`/api/product/delete/${productId}`).set('Authorization', `Bearer ${adminToken}`);
|
|
162
|
+
assert.equal(res.status, 200);
|
|
163
|
+
assert.equal((await request(app).get(`/api/product/${productId}`)).status, 404);
|
|
164
|
+
productId = null;
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('Errors', () => {
|
|
169
|
+
it('unknown route returns JSON 404', async () => {
|
|
170
|
+
const res = await request(app).get('/api/does-not-exist');
|
|
171
|
+
assert.equal(res.status, 404);
|
|
172
|
+
assert.match(res.body.message, /Route not found/);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import AppError from './AppError.js';
|
|
3
|
+
|
|
4
|
+
const verifyToken = (token, secret, message = 'Invalid or expired token') => {
|
|
5
|
+
try {
|
|
6
|
+
return jwt.verify(token, secret);
|
|
7
|
+
} catch {
|
|
8
|
+
throw new AppError(message, 401);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default verifyToken;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const email = z.string().trim().toLowerCase().pipe(z.email());
|
|
4
|
+
|
|
5
|
+
export const password = z.string().min(6, 'Password must be at least 6 characters').max(72);
|
|
6
|
+
|
|
7
|
+
export const name = z.string().trim().min(2, 'Name must be at least 2 characters').max(255);
|
|
8
|
+
|
|
9
|
+
export const registerSchema = z.object({
|
|
10
|
+
name,
|
|
11
|
+
email,
|
|
12
|
+
password,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const loginSchema = z.object({
|
|
16
|
+
email,
|
|
17
|
+
password: z.string().min(1, 'Password is required'),
|
|
18
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const productFields = {
|
|
4
|
+
name: z.string().trim().min(2).max(255),
|
|
5
|
+
description: z.string().trim().max(5000).nullable().optional(),
|
|
6
|
+
price: z.coerce.number().nonnegative().max(99999999.99),
|
|
7
|
+
stock: z.coerce.number().int().nonnegative().optional(),
|
|
8
|
+
image_url: z.url().nullable().optional(),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const createProductSchema = z.object(productFields);
|
|
12
|
+
|
|
13
|
+
export const updateProductSchema = z
|
|
14
|
+
.object(productFields)
|
|
15
|
+
.partial()
|
|
16
|
+
.strict()
|
|
17
|
+
.refine((data) => Object.keys(data).length > 0, { message: 'At least one field is required' });
|
|
18
|
+
|
|
19
|
+
export const productIdSchema = z.object({
|
|
20
|
+
id: z.coerce.number().int().positive(),
|
|
21
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ROLES } from '../config/permissions.js';
|
|
3
|
+
import { name, email, password } from './authValidation.js';
|
|
4
|
+
|
|
5
|
+
export const createUserSchema = z.object({
|
|
6
|
+
name,
|
|
7
|
+
email,
|
|
8
|
+
password,
|
|
9
|
+
role: z.enum(Object.values(ROLES)).optional(),
|
|
10
|
+
});
|