@sundaysf/cli-v3 0.0.1
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 +302 -0
- package/dist/cli.js +1327 -0
- package/package.json +55 -0
- package/templates/api/.claude/agents/knex-table-implementer.md +36 -0
- package/templates/api/.claude/agents/sundays-backend-builder.md +32 -0
- package/templates/api/.env.example +25 -0
- package/templates/api/.github/workflows/ci.yaml +45 -0
- package/templates/api/.github/workflows/deploy.yaml +72 -0
- package/templates/api/.prettierignore +5 -0
- package/templates/api/.prettierrc +9 -0
- package/templates/api/.sundaysrc +8 -0
- package/templates/api/CLAUDE.md +81 -0
- package/templates/api/Dockerfile +17 -0
- package/templates/api/README.md +164 -0
- package/templates/api/_dockerignore +8 -0
- package/templates/api/_gitignore +23 -0
- package/templates/api/_package.json +58 -0
- package/templates/api/docker-compose.yml +21 -0
- package/templates/api/eslint.config.js +27 -0
- package/templates/api/jest.config.js +33 -0
- package/templates/api/jest.setup.js +25 -0
- package/templates/api/knexfile.ts +5 -0
- package/templates/api/src/app.ts +48 -0
- package/templates/api/src/common/__tests__/common.test.ts +116 -0
- package/templates/api/src/common/config/env.ts +53 -0
- package/templates/api/src/common/errors/http.error.ts +30 -0
- package/templates/api/src/common/logger/index.ts +25 -0
- package/templates/api/src/common/utils/environment.resolver.ts +7 -0
- package/templates/api/src/common/utils/pagination.ts +25 -0
- package/templates/api/src/common/utils/version.resolver.ts +24 -0
- package/templates/api/src/common/validation/parse-dto.ts +20 -0
- package/templates/api/src/controllers/health/__tests__/health.controller.test.ts +52 -0
- package/templates/api/src/controllers/health/health.controller.ts +26 -0
- package/templates/api/src/db/BaseDAO.ts +92 -0
- package/templates/api/src/db/KnexConnection.ts +59 -0
- package/templates/api/src/db/__tests__/base-dao.test.ts +73 -0
- package/templates/api/src/db/__tests__/index.barrel.test.ts +10 -0
- package/templates/api/src/db/__tests__/knex-connection.test.ts +90 -0
- package/templates/api/src/db/d.types.ts +42 -0
- package/templates/api/src/db/dao/sundays-package-version/sundays-package-version.dao.ts +12 -0
- package/templates/api/src/db/index.ts +17 -0
- package/templates/api/src/db/interfaces/sundays-package-version/sundays-package-version.interfaces.ts +5 -0
- package/templates/api/src/db/knex.config.ts +46 -0
- package/templates/api/src/dto/input/.gitkeep +0 -0
- package/templates/api/src/jobs/.gitkeep +0 -0
- package/templates/api/src/middlewares/error/__tests__/error.middleware.test.ts +117 -0
- package/templates/api/src/middlewares/error/error.middleware.ts +70 -0
- package/templates/api/src/middlewares/not-found/__tests__/not-found.middleware.test.ts +54 -0
- package/templates/api/src/middlewares/not-found/not-found.middleware.ts +51 -0
- package/templates/api/src/middlewares/request-id/__tests__/request-id.middleware.test.ts +31 -0
- package/templates/api/src/middlewares/request-id/request-id.middleware.ts +20 -0
- package/templates/api/src/migrations/20240101000000_create_sundays_package_version.ts +15 -0
- package/templates/api/src/routes/__tests__/index-router.test.ts +61 -0
- package/templates/api/src/routes/health/__tests__/health.routes.test.ts +22 -0
- package/templates/api/src/routes/health/health.router.ts +18 -0
- package/templates/api/src/routes/index.ts +77 -0
- package/templates/api/src/seeds/001_sundays_package_version.ts +14 -0
- package/templates/api/src/server.ts +56 -0
- package/templates/api/src/services/.gitkeep +0 -0
- package/templates/api/tsconfig.json +20 -0
- package/templates/api/tsconfig.spec.json +10 -0
- package/templates/api-auth/overlay.json +95 -0
- package/templates/api-auth/src/controllers/auth/__tests__/auth.controller.test.ts +194 -0
- package/templates/api-auth/src/controllers/auth/auth.controller.ts +109 -0
- package/templates/api-auth/src/db/dao/auth/auth.dao.ts +21 -0
- package/templates/api-auth/src/db/dao/user/user.dao.ts +24 -0
- package/templates/api-auth/src/db/interfaces/auth/auth.interfaces.ts +8 -0
- package/templates/api-auth/src/db/interfaces/user/user.interfaces.ts +11 -0
- package/templates/api-auth/src/dto/input/auth/auth.login.dto.ts +14 -0
- package/templates/api-auth/src/dto/input/auth/auth.register.dto.ts +19 -0
- package/templates/api-auth/src/middlewares/auth/__tests__/auth.middleware.test.ts +52 -0
- package/templates/api-auth/src/middlewares/auth/auth.middleware.ts +56 -0
- package/templates/api-auth/src/migrations/20240101000001_create_user.ts +18 -0
- package/templates/api-auth/src/migrations/20240101000002_create_auth.ts +24 -0
- package/templates/api-auth/src/routes/auth/__tests__/auth.routes.test.ts +83 -0
- package/templates/api-auth/src/routes/auth/auth.router.ts +28 -0
- package/templates/api-auth/src/services/jwt/__tests__/jwt.service.test.ts +32 -0
- package/templates/api-auth/src/services/jwt/jwt.service.ts +36 -0
- package/templates/api-auth/src/services/password/__tests__/password.service.test.ts +13 -0
- package/templates/api-auth/src/services/password/password.service.ts +14 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import {
|
|
3
|
+
conflict,
|
|
4
|
+
notFound,
|
|
5
|
+
unauthorized,
|
|
6
|
+
} from '../../common/errors/http.error';
|
|
7
|
+
import { AuthDAO, KnexManager, UserDAO } from '../../db';
|
|
8
|
+
import type { IUser } from '../../db';
|
|
9
|
+
import { validateAuthLogin } from '../../dto/input/auth/auth.login.dto';
|
|
10
|
+
import { validateAuthRegister } from '../../dto/input/auth/auth.register.dto';
|
|
11
|
+
import { JwtService } from '../../services/jwt/jwt.service';
|
|
12
|
+
import { PasswordService } from '../../services/password/password.service';
|
|
13
|
+
|
|
14
|
+
export class AuthController {
|
|
15
|
+
private _userDAO = new UserDAO();
|
|
16
|
+
private _authDAO = new AuthDAO();
|
|
17
|
+
private _jwtService = new JwtService();
|
|
18
|
+
private _passwordService = new PasswordService();
|
|
19
|
+
|
|
20
|
+
private issueToken(user: IUser): string {
|
|
21
|
+
return this._jwtService.sign({
|
|
22
|
+
sub: user.uuid!,
|
|
23
|
+
userId: user.id!,
|
|
24
|
+
email: user.email,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** POST /api/auth/register */
|
|
29
|
+
public async register(
|
|
30
|
+
req: Request,
|
|
31
|
+
res: Response,
|
|
32
|
+
next: NextFunction
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
const input = validateAuthRegister(req.body);
|
|
36
|
+
if (await this._userDAO.getByEmail(input.email)) {
|
|
37
|
+
throw conflict('Email already registered');
|
|
38
|
+
}
|
|
39
|
+
const passwordHash = await this._passwordService.hash(input.password);
|
|
40
|
+
const user = await KnexManager.getConnection().transaction(
|
|
41
|
+
async (trx) => {
|
|
42
|
+
const created = await this._userDAO.create(
|
|
43
|
+
{
|
|
44
|
+
email: input.email,
|
|
45
|
+
firstName: input.firstName,
|
|
46
|
+
lastName: input.lastName,
|
|
47
|
+
},
|
|
48
|
+
trx
|
|
49
|
+
);
|
|
50
|
+
await this._authDAO.create(
|
|
51
|
+
{ userId: created.id!, password: passwordHash },
|
|
52
|
+
trx
|
|
53
|
+
);
|
|
54
|
+
return created;
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
res.status(201).json({
|
|
58
|
+
success: true,
|
|
59
|
+
data: { token: this.issueToken(user), user: UserDAO.toPublic(user) },
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
next(err);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** POST /api/auth/login */
|
|
67
|
+
public async login(
|
|
68
|
+
req: Request,
|
|
69
|
+
res: Response,
|
|
70
|
+
next: NextFunction
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
try {
|
|
73
|
+
const input = validateAuthLogin(req.body);
|
|
74
|
+
const user = await this._userDAO.getByEmail(input.email);
|
|
75
|
+
const auth = user?.id ? await this._authDAO.getByUserId(user.id) : null;
|
|
76
|
+
const valid = auth
|
|
77
|
+
? await this._passwordService.compare(input.password, auth.password)
|
|
78
|
+
: false;
|
|
79
|
+
if (!user || !auth || !valid) {
|
|
80
|
+
throw unauthorized('Invalid credentials');
|
|
81
|
+
}
|
|
82
|
+
if (user.isActive === false) {
|
|
83
|
+
throw unauthorized('Account is disabled');
|
|
84
|
+
}
|
|
85
|
+
await this._authDAO.touchLastLogin(auth.id!);
|
|
86
|
+
res.status(200).json({
|
|
87
|
+
success: true,
|
|
88
|
+
data: { token: this.issueToken(user), user: UserDAO.toPublic(user) },
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
next(err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** GET /api/auth/me (authMiddleware) */
|
|
96
|
+
public async me(
|
|
97
|
+
req: Request,
|
|
98
|
+
res: Response,
|
|
99
|
+
next: NextFunction
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
try {
|
|
102
|
+
const user = await this._userDAO.getByUuid(req.auth!.sub);
|
|
103
|
+
if (!user) throw notFound('User not found');
|
|
104
|
+
res.status(200).json({ success: true, data: UserDAO.toPublic(user) });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
next(err);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import { BaseDAO } from '../../BaseDAO';
|
|
3
|
+
import type { IAuth } from '../../interfaces/auth/auth.interfaces';
|
|
4
|
+
|
|
5
|
+
export class AuthDAO extends BaseDAO<IAuth> {
|
|
6
|
+
protected readonly table = 'auth';
|
|
7
|
+
|
|
8
|
+
async getByUserId(
|
|
9
|
+
userId: number,
|
|
10
|
+
trx?: Knex.Transaction
|
|
11
|
+
): Promise<IAuth | null> {
|
|
12
|
+
const row = await this.q(trx).where({ userId }).first();
|
|
13
|
+
return (row as IAuth | undefined) ?? null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async touchLastLogin(id: number, trx?: Knex.Transaction): Promise<void> {
|
|
17
|
+
await this.q(trx)
|
|
18
|
+
.where({ id })
|
|
19
|
+
.update({ lastLoginAt: this._knex.fn.now() });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import { BaseDAO } from '../../BaseDAO';
|
|
3
|
+
import type { IPublicUser, IUser } from '../../interfaces/user/user.interfaces';
|
|
4
|
+
|
|
5
|
+
export class UserDAO extends BaseDAO<IUser> {
|
|
6
|
+
protected readonly table = 'user';
|
|
7
|
+
|
|
8
|
+
async getByEmail(
|
|
9
|
+
email: string,
|
|
10
|
+
trx?: Knex.Transaction
|
|
11
|
+
): Promise<IUser | null> {
|
|
12
|
+
const row = await this.q(trx)
|
|
13
|
+
.whereRaw('lower(email) = lower(?)', [email])
|
|
14
|
+
.first();
|
|
15
|
+
return (row as IUser | undefined) ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Strips the internal id (and anything else that must not leave the API). */
|
|
19
|
+
static toPublic(user: IUser): IPublicUser {
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
21
|
+
const { id, ...rest } = user;
|
|
22
|
+
return rest;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IEntity } from '../../d.types';
|
|
2
|
+
|
|
3
|
+
export interface IUser extends IEntity {
|
|
4
|
+
email: string;
|
|
5
|
+
firstName: string;
|
|
6
|
+
lastName: string;
|
|
7
|
+
isActive?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Shape exposed by the API: no internal id. */
|
|
11
|
+
export type IPublicUser = Omit<IUser, 'id'>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { parseDto } from '../../../common/validation/parse-dto';
|
|
3
|
+
|
|
4
|
+
export const AuthLoginSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
email: z.email().trim().toLowerCase().max(255),
|
|
7
|
+
password: z.string().min(1).max(128),
|
|
8
|
+
})
|
|
9
|
+
.strict();
|
|
10
|
+
|
|
11
|
+
export type AuthLoginInput = z.infer<typeof AuthLoginSchema>;
|
|
12
|
+
|
|
13
|
+
export const validateAuthLogin = (input: unknown): AuthLoginInput =>
|
|
14
|
+
parseDto(AuthLoginSchema, input);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { parseDto } from '../../../common/validation/parse-dto';
|
|
3
|
+
|
|
4
|
+
export const AuthRegisterSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
email: z.email().trim().toLowerCase().max(255),
|
|
7
|
+
password: z
|
|
8
|
+
.string()
|
|
9
|
+
.min(8, 'Password must be at least 8 characters')
|
|
10
|
+
.max(128),
|
|
11
|
+
firstName: z.string().trim().min(1).max(100),
|
|
12
|
+
lastName: z.string().trim().min(1).max(100),
|
|
13
|
+
})
|
|
14
|
+
.strict();
|
|
15
|
+
|
|
16
|
+
export type AuthRegisterInput = z.infer<typeof AuthRegisterSchema>;
|
|
17
|
+
|
|
18
|
+
export const validateAuthRegister = (input: unknown): AuthRegisterInput =>
|
|
19
|
+
parseDto(AuthRegisterSchema, input);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import { HttpError } from '../../../common/errors/http.error';
|
|
3
|
+
import { JwtService } from '../../../services/jwt/jwt.service';
|
|
4
|
+
import { authMiddleware, optionalAuthMiddleware } from '../auth.middleware';
|
|
5
|
+
|
|
6
|
+
const build = (authorization?: string) => {
|
|
7
|
+
const req = { header: jest.fn(() => authorization) } as unknown as Request;
|
|
8
|
+
const res = {} as Response;
|
|
9
|
+
const next = jest.fn() as jest.MockedFunction<NextFunction>;
|
|
10
|
+
return { req, res, next };
|
|
11
|
+
};
|
|
12
|
+
const payload = { sub: 'uuid-1', userId: 1, email: 'a@b.co' };
|
|
13
|
+
const token = new JwtService().sign(payload);
|
|
14
|
+
|
|
15
|
+
describe('authMiddleware', () => {
|
|
16
|
+
it('sets req.auth for a valid bearer token', () => {
|
|
17
|
+
const { req, res, next } = build(`Bearer ${token}`);
|
|
18
|
+
authMiddleware(req, res, next);
|
|
19
|
+
expect(req.auth).toEqual(payload);
|
|
20
|
+
expect(next).toHaveBeenCalledWith();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each([undefined, 'Basic abc', 'Bearer ', 'Bearer not-a-token'])(
|
|
24
|
+
'rejects header %p with 401',
|
|
25
|
+
(header) => {
|
|
26
|
+
const { req, res, next } = build(header);
|
|
27
|
+
authMiddleware(req, res, next);
|
|
28
|
+
const err = next.mock.calls[0][0] as unknown as HttpError;
|
|
29
|
+
expect(err).toBeInstanceOf(HttpError);
|
|
30
|
+
expect(err.statusCode).toBe(401);
|
|
31
|
+
expect(req.auth).toBeUndefined();
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('optionalAuthMiddleware', () => {
|
|
37
|
+
it('sets req.auth when the token is valid', () => {
|
|
38
|
+
const { req, res, next } = build(`Bearer ${token}`);
|
|
39
|
+
optionalAuthMiddleware(req, res, next);
|
|
40
|
+
expect(req.auth).toEqual(payload);
|
|
41
|
+
expect(next).toHaveBeenCalledWith();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('continues anonymously without or with an invalid token', () => {
|
|
45
|
+
for (const header of [undefined, 'Bearer garbage']) {
|
|
46
|
+
const { req, res, next } = build(header);
|
|
47
|
+
optionalAuthMiddleware(req, res, next);
|
|
48
|
+
expect(req.auth).toBeUndefined();
|
|
49
|
+
expect(next).toHaveBeenCalledWith();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import { unauthorized } from '../../common/errors/http.error';
|
|
3
|
+
import { JwtService, type IJwtPayload } from '../../services/jwt/jwt.service';
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
7
|
+
namespace Express {
|
|
8
|
+
interface Request {
|
|
9
|
+
/** Set by authMiddleware / optionalAuthMiddleware. */
|
|
10
|
+
auth?: IJwtPayload;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const readBearer = (req: Request): string | null => {
|
|
16
|
+
const header = req.header('authorization');
|
|
17
|
+
if (!header || !header.startsWith('Bearer ')) return null;
|
|
18
|
+
const token = header.slice('Bearer '.length).trim();
|
|
19
|
+
return token.length ? token : null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Requires a valid `Authorization: Bearer <token>`; puts the payload in `req.auth`. */
|
|
23
|
+
export const authMiddleware = (
|
|
24
|
+
req: Request,
|
|
25
|
+
_res: Response,
|
|
26
|
+
next: NextFunction
|
|
27
|
+
): void => {
|
|
28
|
+
const token = readBearer(req);
|
|
29
|
+
if (!token) {
|
|
30
|
+
next(unauthorized('Missing or invalid Authorization header'));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
req.auth = new JwtService().verify(token);
|
|
35
|
+
next();
|
|
36
|
+
} catch {
|
|
37
|
+
next(unauthorized('Invalid or expired token'));
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Like authMiddleware but never rejects: anonymous requests continue with `req.auth` unset. */
|
|
42
|
+
export const optionalAuthMiddleware = (
|
|
43
|
+
req: Request,
|
|
44
|
+
_res: Response,
|
|
45
|
+
next: NextFunction
|
|
46
|
+
): void => {
|
|
47
|
+
const token = readBearer(req);
|
|
48
|
+
if (token) {
|
|
49
|
+
try {
|
|
50
|
+
req.auth = new JwtService().verify(token);
|
|
51
|
+
} catch {
|
|
52
|
+
// invalid token -> treated as anonymous
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
next();
|
|
56
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
|
|
3
|
+
export async function up(knex: Knex): Promise<void> {
|
|
4
|
+
await knex.schema.createTable('user', (table) => {
|
|
5
|
+
table.increments('id').primary();
|
|
6
|
+
table.uuid('uuid').notNullable().unique();
|
|
7
|
+
table.string('email', 255).notNullable().unique();
|
|
8
|
+
table.string('firstName', 100).notNullable();
|
|
9
|
+
table.string('lastName', 100).notNullable();
|
|
10
|
+
table.boolean('isActive').notNullable().defaultTo(true);
|
|
11
|
+
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
|
12
|
+
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function down(knex: Knex): Promise<void> {
|
|
17
|
+
await knex.schema.dropTableIfExists('user');
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
|
|
3
|
+
/** Credentials live apart from the profile so `user` rows can be exposed safely. */
|
|
4
|
+
export async function up(knex: Knex): Promise<void> {
|
|
5
|
+
await knex.schema.createTable('auth', (table) => {
|
|
6
|
+
table.increments('id').primary();
|
|
7
|
+
table.uuid('uuid').notNullable().unique();
|
|
8
|
+
table
|
|
9
|
+
.integer('userId')
|
|
10
|
+
.notNullable()
|
|
11
|
+
.unique()
|
|
12
|
+
.references('id')
|
|
13
|
+
.inTable('user')
|
|
14
|
+
.onDelete('CASCADE');
|
|
15
|
+
table.string('password', 255).notNullable();
|
|
16
|
+
table.timestamp('lastLoginAt').nullable();
|
|
17
|
+
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
|
18
|
+
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function down(knex: Knex): Promise<void> {
|
|
23
|
+
await knex.schema.dropTableIfExists('auth');
|
|
24
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import app from '../../../app';
|
|
4
|
+
import { KnexManager } from '../../../db';
|
|
5
|
+
|
|
6
|
+
// Integration: real Postgres (docker compose up -d) with migrations applied.
|
|
7
|
+
describe('/api/auth', () => {
|
|
8
|
+
const email = `auth-${randomUUID().slice(0, 8)}@example.com`;
|
|
9
|
+
const password = 'secret123';
|
|
10
|
+
let token = '';
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
await KnexManager.connect();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterAll(async () => {
|
|
17
|
+
await KnexManager.getConnection()('user').where({ email }).delete();
|
|
18
|
+
await KnexManager.disconnect();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('registers a user', async () => {
|
|
22
|
+
const res = await request(app)
|
|
23
|
+
.post('/api/auth/register')
|
|
24
|
+
.send({ email, password, firstName: 'Ada', lastName: 'Lovelace' });
|
|
25
|
+
expect(res.status).toBe(201);
|
|
26
|
+
expect(res.body.success).toBe(true);
|
|
27
|
+
expect(res.body.data.user).toMatchObject({ email, firstName: 'Ada' });
|
|
28
|
+
expect(res.body.data.user.id).toBeUndefined();
|
|
29
|
+
expect(res.body.data.user.uuid).toEqual(expect.any(String));
|
|
30
|
+
expect(res.body.data.token).toEqual(expect.any(String));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects the same email twice', async () => {
|
|
34
|
+
const res = await request(app)
|
|
35
|
+
.post('/api/auth/register')
|
|
36
|
+
.send({ email, password, firstName: 'Ada', lastName: 'Lovelace' });
|
|
37
|
+
expect(res.status).toBe(409);
|
|
38
|
+
expect(res.body).toEqual({
|
|
39
|
+
success: false,
|
|
40
|
+
message: 'Email already registered',
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('validates the body', async () => {
|
|
45
|
+
const res = await request(app)
|
|
46
|
+
.post('/api/auth/register')
|
|
47
|
+
.send({ email: 'nope', password: '1' });
|
|
48
|
+
expect(res.status).toBe(400);
|
|
49
|
+
expect(res.body.message).toBe('Validation failed');
|
|
50
|
+
expect(res.body.errors).toHaveProperty('email');
|
|
51
|
+
expect(res.body.errors).toHaveProperty('password');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('logs in', async () => {
|
|
55
|
+
const res = await request(app)
|
|
56
|
+
.post('/api/auth/login')
|
|
57
|
+
.send({ email: email.toUpperCase(), password });
|
|
58
|
+
expect(res.status).toBe(200);
|
|
59
|
+
token = res.body.data.token;
|
|
60
|
+
expect(token).toEqual(expect.any(String));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects bad credentials', async () => {
|
|
64
|
+
const res = await request(app)
|
|
65
|
+
.post('/api/auth/login')
|
|
66
|
+
.send({ email, password: 'wrong' });
|
|
67
|
+
expect(res.status).toBe(401);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns the profile with a bearer token', async () => {
|
|
71
|
+
const res = await request(app)
|
|
72
|
+
.get('/api/auth/me')
|
|
73
|
+
.set('Authorization', `Bearer ${token}`);
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
expect(res.body.data.email).toBe(email);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects /me without a token', async () => {
|
|
79
|
+
const res = await request(app).get('/api/auth/me');
|
|
80
|
+
expect(res.status).toBe(401);
|
|
81
|
+
expect(res.body.success).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { AuthController } from '../../controllers/auth/auth.controller';
|
|
3
|
+
import { authMiddleware } from '../../middlewares/auth/auth.middleware';
|
|
4
|
+
|
|
5
|
+
export class AuthRouter {
|
|
6
|
+
public router: Router = Router();
|
|
7
|
+
private readonly _authController = new AuthController();
|
|
8
|
+
|
|
9
|
+
constructor() {
|
|
10
|
+
this.initRoutes();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private initRoutes(): void {
|
|
14
|
+
this.router.post(
|
|
15
|
+
'/register',
|
|
16
|
+
this._authController.register.bind(this._authController)
|
|
17
|
+
);
|
|
18
|
+
this.router.post(
|
|
19
|
+
'/login',
|
|
20
|
+
this._authController.login.bind(this._authController)
|
|
21
|
+
);
|
|
22
|
+
this.router.get(
|
|
23
|
+
'/me',
|
|
24
|
+
authMiddleware,
|
|
25
|
+
this._authController.me.bind(this._authController)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import { JwtService } from '../jwt.service';
|
|
3
|
+
|
|
4
|
+
describe('JwtService', () => {
|
|
5
|
+
const service = new JwtService();
|
|
6
|
+
const payload = { sub: 'user-uuid', userId: 1, email: 'a@b.co' };
|
|
7
|
+
|
|
8
|
+
it('signs and verifies a payload', () => {
|
|
9
|
+
const token = service.sign(payload);
|
|
10
|
+
expect(service.verify(token)).toEqual(payload);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('rejects a token signed with another secret', () => {
|
|
14
|
+
const forged = jwt.sign(
|
|
15
|
+
payload,
|
|
16
|
+
'another-secret-that-is-long-enough-123456'
|
|
17
|
+
);
|
|
18
|
+
expect(() => service.verify(forged)).toThrow();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('rejects an expired token', () => {
|
|
22
|
+
const expired = jwt.sign(payload, process.env.JWT_SECRET!, {
|
|
23
|
+
expiresIn: -10,
|
|
24
|
+
});
|
|
25
|
+
expect(() => service.verify(expired)).toThrow(/expired/i);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects a payload without subject', () => {
|
|
29
|
+
const noSub = jwt.sign({ userId: 1 }, process.env.JWT_SECRET!);
|
|
30
|
+
expect(() => service.verify(noSub)).toThrow(/Invalid token payload/);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import jwt, { type SignOptions } from 'jsonwebtoken';
|
|
2
|
+
import { env } from '../../common/config/env';
|
|
3
|
+
|
|
4
|
+
export interface IJwtPayload {
|
|
5
|
+
/** Subject: the user's public uuid. */
|
|
6
|
+
sub: string;
|
|
7
|
+
userId: number;
|
|
8
|
+
email: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Signs and verifies access tokens with JWT_SECRET / JWT_EXPIRES_IN. */
|
|
12
|
+
export class JwtService {
|
|
13
|
+
private readonly _secret: string = env.JWT_SECRET;
|
|
14
|
+
private readonly _expiresIn: string = env.JWT_EXPIRES_IN;
|
|
15
|
+
|
|
16
|
+
public sign(payload: IJwtPayload): string {
|
|
17
|
+
const options: SignOptions = {
|
|
18
|
+
expiresIn: this._expiresIn as SignOptions['expiresIn'],
|
|
19
|
+
};
|
|
20
|
+
return jwt.sign(payload, this._secret, options);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Returns the payload or throws (invalid signature, malformed, expired). */
|
|
24
|
+
public verify(token: string): IJwtPayload {
|
|
25
|
+
const decoded = jwt.verify(token, this._secret);
|
|
26
|
+
if (
|
|
27
|
+
typeof decoded !== 'object' ||
|
|
28
|
+
decoded === null ||
|
|
29
|
+
typeof decoded.sub !== 'string'
|
|
30
|
+
) {
|
|
31
|
+
throw new Error('Invalid token payload');
|
|
32
|
+
}
|
|
33
|
+
const { sub, userId, email } = decoded as IJwtPayload;
|
|
34
|
+
return { sub, userId, email };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { PasswordService } from '../password.service';
|
|
2
|
+
|
|
3
|
+
describe('PasswordService', () => {
|
|
4
|
+
const service = new PasswordService(4);
|
|
5
|
+
|
|
6
|
+
it('hashes and compares', async () => {
|
|
7
|
+
const hash = await service.hash('secret123');
|
|
8
|
+
expect(hash).not.toBe('secret123');
|
|
9
|
+
expect(hash).toMatch(/^\$2[aby]\$/);
|
|
10
|
+
expect(await service.compare('secret123', hash)).toBe(true);
|
|
11
|
+
expect(await service.compare('nope', hash)).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import bcrypt from 'bcryptjs';
|
|
2
|
+
|
|
3
|
+
/** bcrypt hashing (bcryptjs: pure JS, same format as native bcrypt, no build step). */
|
|
4
|
+
export class PasswordService {
|
|
5
|
+
constructor(private readonly _saltRounds: number = 10) {}
|
|
6
|
+
|
|
7
|
+
public hash(plain: string): Promise<string> {
|
|
8
|
+
return bcrypt.hash(plain, this._saltRounds);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public compare(plain: string, hash: string): Promise<boolean> {
|
|
12
|
+
return bcrypt.compare(plain, hash);
|
|
13
|
+
}
|
|
14
|
+
}
|