create-tigra 1.0.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/LICENSE +21 -0
- package/README.md +87 -0
- package/bin/create-tigra.js +292 -0
- package/package.json +41 -0
- package/template/.agent/rules/client/01-project-structure.md +326 -0
- package/template/.agent/rules/client/02-component-patterns.md +249 -0
- package/template/.agent/rules/client/03-typescript-rules.md +226 -0
- package/template/.agent/rules/client/04-state-management.md +474 -0
- package/template/.agent/rules/client/05-api-integration.md +129 -0
- package/template/.agent/rules/client/06-forms-validation.md +129 -0
- package/template/.agent/rules/client/07-common-patterns.md +150 -0
- package/template/.agent/rules/client/08-color-system.md +93 -0
- package/template/.agent/rules/client/09-security-rules.md +97 -0
- package/template/.agent/rules/client/10-testing-strategy.md +370 -0
- package/template/.agent/rules/global/ai-edit-safety.md +38 -0
- package/template/.agent/rules/server/01-db-and-migrations.md +242 -0
- package/template/.agent/rules/server/02-general-rules.md +111 -0
- package/template/.agent/rules/server/03-migrations.md +20 -0
- package/template/.agent/rules/server/04-pagination.md +130 -0
- package/template/.agent/rules/server/05-project-conventions.md +71 -0
- package/template/.agent/rules/server/06-response-handling.md +173 -0
- package/template/.agent/rules/server/07-testing-strategy.md +506 -0
- package/template/.agent/rules/server/08-observability.md +180 -0
- package/template/.agent/rules/server/09-api-documentation-v2.md +168 -0
- package/template/.agent/rules/server/10-background-jobs-v2.md +185 -0
- package/template/.agent/rules/server/11-rate-limiting-v2.md +210 -0
- package/template/.agent/rules/server/12-performance-optimization.md +567 -0
- package/template/.claude/rules/client-01-project-structure.md +327 -0
- package/template/.claude/rules/client-02-component-patterns.md +250 -0
- package/template/.claude/rules/client-03-typescript-rules.md +227 -0
- package/template/.claude/rules/client-04-state-management.md +475 -0
- package/template/.claude/rules/client-05-api-integration.md +130 -0
- package/template/.claude/rules/client-06-forms-validation.md +130 -0
- package/template/.claude/rules/client-07-common-patterns.md +151 -0
- package/template/.claude/rules/client-08-color-system.md +94 -0
- package/template/.claude/rules/client-09-security-rules.md +98 -0
- package/template/.claude/rules/client-10-testing-strategy.md +371 -0
- package/template/.claude/rules/global-ai-edit-safety.md +39 -0
- package/template/.claude/rules/server-01-db-and-migrations.md +243 -0
- package/template/.claude/rules/server-02-general-rules.md +112 -0
- package/template/.claude/rules/server-03-migrations.md +21 -0
- package/template/.claude/rules/server-04-pagination.md +131 -0
- package/template/.claude/rules/server-05-project-conventions.md +72 -0
- package/template/.claude/rules/server-06-response-handling.md +174 -0
- package/template/.claude/rules/server-07-testing-strategy.md +507 -0
- package/template/.claude/rules/server-08-observability.md +181 -0
- package/template/.claude/rules/server-09-api-documentation-v2.md +169 -0
- package/template/.claude/rules/server-10-background-jobs-v2.md +186 -0
- package/template/.claude/rules/server-11-rate-limiting-v2.md +211 -0
- package/template/.claude/rules/server-12-performance-optimization.md +568 -0
- package/template/.cursor/rules/client-01-project-structure.mdc +327 -0
- package/template/.cursor/rules/client-02-component-patterns.mdc +250 -0
- package/template/.cursor/rules/client-03-typescript-rules.mdc +227 -0
- package/template/.cursor/rules/client-04-state-management.mdc +475 -0
- package/template/.cursor/rules/client-05-api-integration.mdc +130 -0
- package/template/.cursor/rules/client-06-forms-validation.mdc +130 -0
- package/template/.cursor/rules/client-07-common-patterns.mdc +151 -0
- package/template/.cursor/rules/client-08-color-system.mdc +94 -0
- package/template/.cursor/rules/client-09-security-rules.mdc +98 -0
- package/template/.cursor/rules/client-10-testing-strategy.mdc +371 -0
- package/template/.cursor/rules/global-ai-edit-safety.mdc +39 -0
- package/template/.cursor/rules/server-01-db-and-migrations.mdc +243 -0
- package/template/.cursor/rules/server-02-general-rules.mdc +112 -0
- package/template/.cursor/rules/server-03-migrations.mdc +21 -0
- package/template/.cursor/rules/server-04-pagination.mdc +131 -0
- package/template/.cursor/rules/server-05-project-conventions.mdc +72 -0
- package/template/.cursor/rules/server-06-response-handling.mdc +174 -0
- package/template/.cursor/rules/server-07-testing-strategy.mdc +507 -0
- package/template/.cursor/rules/server-08-observability.mdc +181 -0
- package/template/.cursor/rules/server-09-api-documentation-v2.mdc +169 -0
- package/template/.cursor/rules/server-10-background-jobs-v2.mdc +186 -0
- package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +211 -0
- package/template/.cursor/rules/server-12-performance-optimization.mdc +568 -0
- package/template/CLAUDE.md +207 -0
- package/template/server/.env.example +148 -0
- package/template/server/.tsc-aliasrc.json +12 -0
- package/template/server/README.md +175 -0
- package/template/server/SECURITY.md +190 -0
- package/template/server/biome.json +42 -0
- package/template/server/docker-compose.yml +111 -0
- package/template/server/package.json +83 -0
- package/template/server/postman_collection.json +733 -0
- package/template/server/prisma/schema.prisma +92 -0
- package/template/server/prisma/seed.ts +142 -0
- package/template/server/scripts/wait-for-db.js +60 -0
- package/template/server/src/app.ts +74 -0
- package/template/server/src/config/env.ts +101 -0
- package/template/server/src/hooks/request-timing.hook.ts +26 -0
- package/template/server/src/libs/auth/authenticate.middleware.ts +22 -0
- package/template/server/src/libs/auth/rbac.middleware.test.ts +134 -0
- package/template/server/src/libs/auth/rbac.middleware.ts +147 -0
- package/template/server/src/libs/db.ts +76 -0
- package/template/server/src/libs/error-handler.ts +89 -0
- package/template/server/src/libs/logger.ts +60 -0
- package/template/server/src/libs/queue.ts +79 -0
- package/template/server/src/libs/redis.ts +79 -0
- package/template/server/src/libs/swagger-schemas.ts +16 -0
- package/template/server/src/modules/admin/admin.controller.ts +122 -0
- package/template/server/src/modules/admin/admin.routes.ts +100 -0
- package/template/server/src/modules/admin/admin.schemas.ts +35 -0
- package/template/server/src/modules/admin/admin.service.ts +167 -0
- package/template/server/src/modules/auth/auth.controller.ts +141 -0
- package/template/server/src/modules/auth/auth.integration.test.ts +150 -0
- package/template/server/src/modules/auth/auth.repo.ts +218 -0
- package/template/server/src/modules/auth/auth.routes.ts +204 -0
- package/template/server/src/modules/auth/auth.schemas.ts +137 -0
- package/template/server/src/modules/auth/auth.service.test.ts +119 -0
- package/template/server/src/modules/auth/auth.service.ts +329 -0
- package/template/server/src/modules/auth/auth.types.ts +97 -0
- package/template/server/src/modules/resources/resources.controller.ts +218 -0
- package/template/server/src/modules/resources/resources.repo.ts +253 -0
- package/template/server/src/modules/resources/resources.routes.ts +355 -0
- package/template/server/src/modules/resources/resources.schemas.ts +146 -0
- package/template/server/src/modules/resources/resources.service.ts +218 -0
- package/template/server/src/modules/resources/resources.types.ts +73 -0
- package/template/server/src/plugins/rate-limit.plugin.ts +21 -0
- package/template/server/src/plugins/security.plugin.ts +21 -0
- package/template/server/src/plugins/swagger.plugin.ts +41 -0
- package/template/server/src/routes/health.routes.ts +31 -0
- package/template/server/src/server.ts +142 -0
- package/template/server/src/test/setup.ts +38 -0
- package/template/server/src/types/fastify.d.ts +36 -0
- package/template/server/src/utils/errors.ts +108 -0
- package/template/server/src/utils/pagination.ts +120 -0
- package/template/server/src/utils/response.ts +110 -0
- package/template/server/src/workers/file.worker.ts +106 -0
- package/template/server/tsconfig.build.json +30 -0
- package/template/server/tsconfig.build.tsbuildinfo +1 -0
- package/template/server/tsconfig.json +89 -0
- package/template/server/tsconfig.test.json +22 -0
- package/template/server/vitest.config.ts +98 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Repository
|
|
3
|
+
*
|
|
4
|
+
* Database operations for authentication module.
|
|
5
|
+
* Handles all Prisma queries related to users and sessions.
|
|
6
|
+
*
|
|
7
|
+
* @see /mnt/project/02-general-rules.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { prisma } from '@/libs/db';
|
|
11
|
+
import type { User as PrismaUser, Session as PrismaSession } from '@prisma/client';
|
|
12
|
+
import type { User } from './auth.types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* User with password (internal use only)
|
|
16
|
+
*/
|
|
17
|
+
type UserWithPassword = PrismaUser;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Session with user relation
|
|
21
|
+
*/
|
|
22
|
+
type SessionWithUser = PrismaSession & {
|
|
23
|
+
user: PrismaUser;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a new user
|
|
28
|
+
*
|
|
29
|
+
* @param data - User creation data
|
|
30
|
+
* @returns User object WITHOUT password field
|
|
31
|
+
*/
|
|
32
|
+
export async function createUser(data: {
|
|
33
|
+
email: string;
|
|
34
|
+
password: string;
|
|
35
|
+
name: string;
|
|
36
|
+
}): Promise<User> {
|
|
37
|
+
const user = await prisma.user.create({
|
|
38
|
+
data: {
|
|
39
|
+
email: data.email,
|
|
40
|
+
password: data.password,
|
|
41
|
+
name: data.name,
|
|
42
|
+
role: 'USER',
|
|
43
|
+
emailVerified: false,
|
|
44
|
+
},
|
|
45
|
+
select: {
|
|
46
|
+
id: true,
|
|
47
|
+
email: true,
|
|
48
|
+
name: true,
|
|
49
|
+
role: true,
|
|
50
|
+
emailVerified: true,
|
|
51
|
+
createdAt: true,
|
|
52
|
+
updatedAt: true,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return user;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Find user by email (without password)
|
|
61
|
+
*
|
|
62
|
+
* @param email - User email
|
|
63
|
+
* @returns User object WITHOUT password field, or null if not found
|
|
64
|
+
*/
|
|
65
|
+
export async function findUserByEmail(email: string): Promise<User | null> {
|
|
66
|
+
const user = await prisma.user.findUnique({
|
|
67
|
+
where: { email },
|
|
68
|
+
select: {
|
|
69
|
+
id: true,
|
|
70
|
+
email: true,
|
|
71
|
+
name: true,
|
|
72
|
+
role: true,
|
|
73
|
+
emailVerified: true,
|
|
74
|
+
createdAt: true,
|
|
75
|
+
updatedAt: true,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return user;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Find user by email WITH password
|
|
84
|
+
*
|
|
85
|
+
* Used for login verification only.
|
|
86
|
+
* NEVER return this to the client.
|
|
87
|
+
*
|
|
88
|
+
* @param email - User email
|
|
89
|
+
* @returns User object WITH password field, or null if not found
|
|
90
|
+
*/
|
|
91
|
+
export async function findUserByEmailWithPassword(
|
|
92
|
+
email: string
|
|
93
|
+
): Promise<UserWithPassword | null> {
|
|
94
|
+
const user = await prisma.user.findUnique({
|
|
95
|
+
where: { email },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return user;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Find user by ID (without password)
|
|
103
|
+
*
|
|
104
|
+
* @param id - User ID
|
|
105
|
+
* @returns User object WITHOUT password field, or null if not found
|
|
106
|
+
*/
|
|
107
|
+
export async function findUserById(id: string): Promise<User | null> {
|
|
108
|
+
const user = await prisma.user.findUnique({
|
|
109
|
+
where: { id },
|
|
110
|
+
select: {
|
|
111
|
+
id: true,
|
|
112
|
+
email: true,
|
|
113
|
+
name: true,
|
|
114
|
+
role: true,
|
|
115
|
+
emailVerified: true,
|
|
116
|
+
createdAt: true,
|
|
117
|
+
updatedAt: true,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return user;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create a new session
|
|
126
|
+
*
|
|
127
|
+
* @param data - Session creation data
|
|
128
|
+
* @returns Created session
|
|
129
|
+
*/
|
|
130
|
+
export async function createSession(data: {
|
|
131
|
+
userId: string;
|
|
132
|
+
refreshToken: string;
|
|
133
|
+
expiresAt: Date;
|
|
134
|
+
}): Promise<PrismaSession> {
|
|
135
|
+
const session = await prisma.session.create({
|
|
136
|
+
data: {
|
|
137
|
+
userId: data.userId,
|
|
138
|
+
refreshToken: data.refreshToken,
|
|
139
|
+
expiresAt: data.expiresAt,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return session;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Find session by refresh token
|
|
148
|
+
*
|
|
149
|
+
* @param refreshToken - Refresh token
|
|
150
|
+
* @returns Session with user relation, or null if not found
|
|
151
|
+
*/
|
|
152
|
+
export async function findSessionByToken(
|
|
153
|
+
refreshToken: string
|
|
154
|
+
): Promise<SessionWithUser | null> {
|
|
155
|
+
const session = await prisma.session.findFirst({
|
|
156
|
+
where: { refreshToken },
|
|
157
|
+
include: {
|
|
158
|
+
user: true,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return session;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Update session with new refresh token
|
|
167
|
+
*
|
|
168
|
+
* @param oldToken - Current refresh token
|
|
169
|
+
* @param newToken - New refresh token
|
|
170
|
+
* @param expiresAt - New expiration date
|
|
171
|
+
* @returns Updated session
|
|
172
|
+
*/
|
|
173
|
+
export async function updateSession(
|
|
174
|
+
oldToken: string,
|
|
175
|
+
newToken: string,
|
|
176
|
+
expiresAt: Date
|
|
177
|
+
): Promise<PrismaSession> {
|
|
178
|
+
// Since refreshToken is no longer unique at DB level, we use updateMany
|
|
179
|
+
// In practice, it should only update one record
|
|
180
|
+
await prisma.session.updateMany({
|
|
181
|
+
where: { refreshToken: oldToken },
|
|
182
|
+
data: {
|
|
183
|
+
refreshToken: newToken,
|
|
184
|
+
expiresAt,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Return the updated session (requires a fetch since updateMany returns a count)
|
|
189
|
+
const session = await prisma.session.findFirstOrThrow({
|
|
190
|
+
where: { refreshToken: newToken },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return session;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Delete session by refresh token
|
|
198
|
+
*
|
|
199
|
+
* @param refreshToken - Refresh token
|
|
200
|
+
*/
|
|
201
|
+
export async function deleteSession(refreshToken: string): Promise<void> {
|
|
202
|
+
await prisma.session.deleteMany({
|
|
203
|
+
where: { refreshToken },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Delete all sessions for a user
|
|
209
|
+
*
|
|
210
|
+
* Used for logout from all devices.
|
|
211
|
+
*
|
|
212
|
+
* @param userId - User ID
|
|
213
|
+
*/
|
|
214
|
+
export async function deleteAllUserSessions(userId: string): Promise<void> {
|
|
215
|
+
await prisma.session.deleteMany({
|
|
216
|
+
where: { userId },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Routes
|
|
3
|
+
*
|
|
4
|
+
* Fastify route definitions for authentication endpoints.
|
|
5
|
+
* Includes Swagger documentation and rate limiting.
|
|
6
|
+
*
|
|
7
|
+
* @see /mnt/project/09-api-documentation-v2.md
|
|
8
|
+
* @see /mnt/project/11-rate-limiting-v2.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { FastifyInstance } from 'fastify';
|
|
12
|
+
import { toJsonSchema, getDefinition } from '@/libs/swagger-schemas';
|
|
13
|
+
import * as authController from './auth.controller';
|
|
14
|
+
import {
|
|
15
|
+
RegisterSchema,
|
|
16
|
+
LoginSchema,
|
|
17
|
+
RefreshTokenSchema,
|
|
18
|
+
AuthResponseSchema,
|
|
19
|
+
UserResponseSchema,
|
|
20
|
+
TokenResponseSchema,
|
|
21
|
+
ErrorResponseSchema,
|
|
22
|
+
} from './auth.schemas';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Register authentication routes
|
|
26
|
+
*
|
|
27
|
+
* @param fastify - Fastify instance
|
|
28
|
+
*/
|
|
29
|
+
export async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
|
30
|
+
/**
|
|
31
|
+
* POST /auth/register
|
|
32
|
+
*
|
|
33
|
+
* Register a new user account
|
|
34
|
+
*/
|
|
35
|
+
fastify.post('/register', {
|
|
36
|
+
schema: {
|
|
37
|
+
description: 'Register a new user account',
|
|
38
|
+
tags: ['auth'],
|
|
39
|
+
summary: 'Register user',
|
|
40
|
+
body: toJsonSchema(RegisterSchema, 'RegisterRequest'),
|
|
41
|
+
response: {
|
|
42
|
+
201: getDefinition(AuthResponseSchema, 'AuthResponse'),
|
|
43
|
+
400: {
|
|
44
|
+
description: 'Validation error',
|
|
45
|
+
...getDefinition(ErrorResponseSchema, 'ErrorResponse'),
|
|
46
|
+
},
|
|
47
|
+
409: {
|
|
48
|
+
description: 'Email already registered',
|
|
49
|
+
...getDefinition(ErrorResponseSchema, 'ErrorResponse'),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
config: {
|
|
54
|
+
rateLimit: {
|
|
55
|
+
max: 3,
|
|
56
|
+
timeWindow: '1 hour',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
handler: authController.register,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* POST /auth/login
|
|
64
|
+
*
|
|
65
|
+
* Login with email and password
|
|
66
|
+
*/
|
|
67
|
+
fastify.post('/login', {
|
|
68
|
+
schema: {
|
|
69
|
+
description: 'Login with email and password',
|
|
70
|
+
tags: ['auth'],
|
|
71
|
+
summary: 'Login user',
|
|
72
|
+
body: toJsonSchema(LoginSchema, 'LoginRequest'),
|
|
73
|
+
response: {
|
|
74
|
+
200: getDefinition(AuthResponseSchema, 'AuthResponse'),
|
|
75
|
+
401: {
|
|
76
|
+
description: 'Invalid credentials',
|
|
77
|
+
...getDefinition(ErrorResponseSchema, 'ErrorResponse'),
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
config: {
|
|
82
|
+
rateLimit: {
|
|
83
|
+
max: 5,
|
|
84
|
+
timeWindow: '15 minutes',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
handler: authController.login,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* POST /auth/refresh
|
|
92
|
+
*
|
|
93
|
+
* Refresh access token using refresh token
|
|
94
|
+
*/
|
|
95
|
+
fastify.post('/refresh', {
|
|
96
|
+
schema: {
|
|
97
|
+
description: 'Refresh access token using refresh token',
|
|
98
|
+
tags: ['auth'],
|
|
99
|
+
summary: 'Refresh tokens',
|
|
100
|
+
body: toJsonSchema(RefreshTokenSchema, 'RefreshTokenRequest'),
|
|
101
|
+
response: {
|
|
102
|
+
200: {
|
|
103
|
+
description: 'Tokens refreshed successfully',
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
success: { type: 'boolean', enum: [true] },
|
|
107
|
+
message: { type: 'string' },
|
|
108
|
+
data: {
|
|
109
|
+
type: 'object',
|
|
110
|
+
properties: {
|
|
111
|
+
accessToken: { type: 'string' },
|
|
112
|
+
refreshToken: { type: 'string' },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
401: {
|
|
118
|
+
description: 'Invalid or expired refresh token',
|
|
119
|
+
...getDefinition(ErrorResponseSchema, 'ErrorResponse'),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
config: {
|
|
124
|
+
rateLimit: {
|
|
125
|
+
max: 10,
|
|
126
|
+
timeWindow: '15 minutes',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
handler: authController.refreshTokens,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* POST /auth/logout
|
|
134
|
+
*
|
|
135
|
+
* Logout user by invalidating refresh token
|
|
136
|
+
*/
|
|
137
|
+
fastify.post('/logout', {
|
|
138
|
+
schema: {
|
|
139
|
+
description: 'Logout user by invalidating refresh token',
|
|
140
|
+
tags: ['auth'],
|
|
141
|
+
summary: 'Logout user',
|
|
142
|
+
body: toJsonSchema(RefreshTokenSchema, 'RefreshTokenRequest'),
|
|
143
|
+
response: {
|
|
144
|
+
200: {
|
|
145
|
+
description: 'Logout successful',
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: {
|
|
148
|
+
success: { type: 'boolean', enum: [true] },
|
|
149
|
+
message: { type: 'string' },
|
|
150
|
+
data: { type: 'null' },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
handler: authController.logout,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* GET /auth/me
|
|
160
|
+
*
|
|
161
|
+
* Get current authenticated user information
|
|
162
|
+
* Requires authentication
|
|
163
|
+
*/
|
|
164
|
+
fastify.get('/me', {
|
|
165
|
+
schema: {
|
|
166
|
+
description: 'Get current authenticated user information',
|
|
167
|
+
tags: ['auth'],
|
|
168
|
+
summary: 'Get current user',
|
|
169
|
+
security: [{ bearerAuth: [] }],
|
|
170
|
+
response: {
|
|
171
|
+
200: {
|
|
172
|
+
description: 'User retrieved successfully',
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
success: { type: 'boolean', enum: [true] },
|
|
176
|
+
message: { type: 'string' },
|
|
177
|
+
data: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
properties: {
|
|
180
|
+
id: { type: 'string', format: 'uuid' },
|
|
181
|
+
email: { type: 'string', format: 'email' },
|
|
182
|
+
name: { type: 'string', nullable: true },
|
|
183
|
+
role: { type: 'string' },
|
|
184
|
+
emailVerified: { type: 'boolean' },
|
|
185
|
+
createdAt: { type: 'string', format: 'date-time' },
|
|
186
|
+
updatedAt: { type: 'string', format: 'date-time' },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
401: {
|
|
192
|
+
description: 'Unauthorized - Invalid or missing token',
|
|
193
|
+
...getDefinition(ErrorResponseSchema, 'ErrorResponse'),
|
|
194
|
+
},
|
|
195
|
+
404: {
|
|
196
|
+
description: 'User not found',
|
|
197
|
+
...getDefinition(ErrorResponseSchema, 'ErrorResponse'),
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
preHandler: [fastify.authenticate],
|
|
202
|
+
handler: authController.getMe,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Schemas
|
|
3
|
+
*
|
|
4
|
+
* Zod schemas for request validation and Swagger documentation.
|
|
5
|
+
* These schemas ensure type safety and API documentation consistency.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Password Validation Regex
|
|
12
|
+
*
|
|
13
|
+
* Requirements:
|
|
14
|
+
* - At least 1 uppercase letter
|
|
15
|
+
* - At least 1 lowercase letter
|
|
16
|
+
* - At least 1 number
|
|
17
|
+
* - Minimum 8 characters
|
|
18
|
+
*/
|
|
19
|
+
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Register Schema
|
|
23
|
+
*
|
|
24
|
+
* Validates user registration requests.
|
|
25
|
+
*/
|
|
26
|
+
export const RegisterSchema = z.object({
|
|
27
|
+
email: z
|
|
28
|
+
.string()
|
|
29
|
+
.email('Invalid email format')
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.trim(),
|
|
32
|
+
password: z
|
|
33
|
+
.string()
|
|
34
|
+
.min(8, 'Password must be at least 8 characters')
|
|
35
|
+
.regex(
|
|
36
|
+
passwordRegex,
|
|
37
|
+
'Password must contain at least 1 uppercase letter, 1 lowercase letter, and 1 number'
|
|
38
|
+
),
|
|
39
|
+
name: z
|
|
40
|
+
.string()
|
|
41
|
+
.min(2, 'Name must be at least 2 characters')
|
|
42
|
+
.max(100, 'Name must not exceed 100 characters')
|
|
43
|
+
.trim(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Login Schema
|
|
48
|
+
*
|
|
49
|
+
* Validates user login requests.
|
|
50
|
+
*/
|
|
51
|
+
export const LoginSchema = z.object({
|
|
52
|
+
email: z
|
|
53
|
+
.string()
|
|
54
|
+
.email('Invalid email format')
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.trim(),
|
|
57
|
+
password: z
|
|
58
|
+
.string()
|
|
59
|
+
.min(8, 'Password must be at least 8 characters'),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Refresh Token Schema
|
|
64
|
+
*
|
|
65
|
+
* Validates refresh token requests.
|
|
66
|
+
*/
|
|
67
|
+
export const RefreshTokenSchema = z.object({
|
|
68
|
+
refreshToken: z
|
|
69
|
+
.string()
|
|
70
|
+
.min(1, 'Refresh token is required'),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* User Response Schema (for Swagger documentation)
|
|
75
|
+
*
|
|
76
|
+
* Defines the structure of user objects in API responses.
|
|
77
|
+
*/
|
|
78
|
+
export const UserResponseSchema = z.object({
|
|
79
|
+
id: z.string().uuid(),
|
|
80
|
+
email: z.string().email(),
|
|
81
|
+
name: z.string().nullable(),
|
|
82
|
+
role: z.enum(['USER', 'ADMIN']),
|
|
83
|
+
emailVerified: z.boolean(),
|
|
84
|
+
createdAt: z.string().datetime(),
|
|
85
|
+
updatedAt: z.string().datetime(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Token Response Schema (for Swagger documentation)
|
|
90
|
+
*
|
|
91
|
+
* Defines the structure of token objects in API responses.
|
|
92
|
+
*/
|
|
93
|
+
export const TokenResponseSchema = z.object({
|
|
94
|
+
accessToken: z.string(),
|
|
95
|
+
refreshToken: z.string(),
|
|
96
|
+
expiresIn: z.number().int().positive(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Auth Response Schema (for Swagger documentation)
|
|
101
|
+
*
|
|
102
|
+
* Complete authentication response structure.
|
|
103
|
+
* Used for login and register endpoints.
|
|
104
|
+
*/
|
|
105
|
+
export const AuthResponseSchema = z.object({
|
|
106
|
+
success: z.literal(true),
|
|
107
|
+
message: z.string(),
|
|
108
|
+
data: z.object({
|
|
109
|
+
user: UserResponseSchema,
|
|
110
|
+
tokens: TokenResponseSchema,
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Error Response Schema (for Swagger documentation)
|
|
116
|
+
*
|
|
117
|
+
* Standard error response structure.
|
|
118
|
+
*/
|
|
119
|
+
export const ErrorResponseSchema = z.object({
|
|
120
|
+
success: z.literal(false),
|
|
121
|
+
error: z.object({
|
|
122
|
+
code: z.string(),
|
|
123
|
+
message: z.string(),
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Type inference from schemas
|
|
129
|
+
*
|
|
130
|
+
* These types can be used in controllers and services.
|
|
131
|
+
*/
|
|
132
|
+
export type RegisterInput = z.infer<typeof RegisterSchema>;
|
|
133
|
+
export type LoginInput = z.infer<typeof LoginSchema>;
|
|
134
|
+
export type RefreshTokenInput = z.infer<typeof RefreshTokenSchema>;
|
|
135
|
+
export type UserResponse = z.infer<typeof UserResponseSchema>;
|
|
136
|
+
export type TokenResponse = z.infer<typeof TokenResponseSchema>;
|
|
137
|
+
export type AuthResponse = z.infer<typeof AuthResponseSchema>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import * as authService from './auth.service';
|
|
3
|
+
import * as authRepo from './auth.repo';
|
|
4
|
+
import bcrypt from 'bcryptjs';
|
|
5
|
+
import jwt from 'jsonwebtoken';
|
|
6
|
+
import { env } from '@/config/env';
|
|
7
|
+
import { ConflictError, UnauthorizedError } from '@/utils/errors';
|
|
8
|
+
|
|
9
|
+
// Mock dependencies
|
|
10
|
+
vi.mock('./auth.repo');
|
|
11
|
+
vi.mock('bcryptjs');
|
|
12
|
+
vi.mock('jsonwebtoken');
|
|
13
|
+
vi.mock('@/libs/logger', () => ({
|
|
14
|
+
default: {
|
|
15
|
+
info: vi.fn(),
|
|
16
|
+
error: vi.fn(),
|
|
17
|
+
warn: vi.fn(),
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
describe('Auth Service', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.resetAllMocks();
|
|
24
|
+
// Setup default env mocks if needed
|
|
25
|
+
env.JWT_SECRET = 'test-secret';
|
|
26
|
+
env.JWT_ACCESS_EXPIRATION = '15m';
|
|
27
|
+
env.JWT_REFRESH_EXPIRATION = '7d';
|
|
28
|
+
env.JWT_ISSUER = 'test-issuer';
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('register', () => {
|
|
32
|
+
const registerData = {
|
|
33
|
+
email: 'test@example.com',
|
|
34
|
+
password: 'Password123!',
|
|
35
|
+
name: 'Test User',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
it('should successfully register a new user', async () => {
|
|
39
|
+
// Mocks
|
|
40
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
41
|
+
vi.mocked(bcrypt.hash).mockResolvedValue('hashed_password' as any);
|
|
42
|
+
vi.mocked(authRepo.createUser).mockResolvedValue({
|
|
43
|
+
id: 'user-123',
|
|
44
|
+
email: registerData.email,
|
|
45
|
+
name: registerData.name,
|
|
46
|
+
role: 'USER',
|
|
47
|
+
emailVerified: false,
|
|
48
|
+
createdAt: new Date(),
|
|
49
|
+
updatedAt: new Date(),
|
|
50
|
+
});
|
|
51
|
+
vi.mocked(jwt.sign).mockReturnValue('mock_token' as any);
|
|
52
|
+
vi.mocked(authRepo.createSession).mockResolvedValue({} as any);
|
|
53
|
+
|
|
54
|
+
// Execute
|
|
55
|
+
const result = await authService.register(registerData);
|
|
56
|
+
|
|
57
|
+
// Assert
|
|
58
|
+
expect(result).toBeDefined();
|
|
59
|
+
expect(result.user.email).toBe(registerData.email);
|
|
60
|
+
expect(result.tokens.accessToken).toBe('mock_token');
|
|
61
|
+
expect(authRepo.createUser).toHaveBeenCalled();
|
|
62
|
+
expect(authRepo.createSession).toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should throw ConflictError if email already exists', async () => {
|
|
66
|
+
// Mocks
|
|
67
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue({ id: 'existing' } as any);
|
|
68
|
+
|
|
69
|
+
// Execute & Assert
|
|
70
|
+
await expect(authService.register(registerData))
|
|
71
|
+
.rejects
|
|
72
|
+
.toThrow(ConflictError);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('login', () => {
|
|
77
|
+
const loginData = {
|
|
78
|
+
email: 'test@example.com',
|
|
79
|
+
password: 'Password123!',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
it('should successfully login user', async () => {
|
|
83
|
+
// Mocks
|
|
84
|
+
vi.mocked(authRepo.findUserByEmailWithPassword).mockResolvedValue({
|
|
85
|
+
id: 'user-123',
|
|
86
|
+
email: loginData.email,
|
|
87
|
+
password: 'hashed_password', // Match what bcrypt.compare expects
|
|
88
|
+
role: 'USER',
|
|
89
|
+
} as any);
|
|
90
|
+
vi.mocked(bcrypt.compare).mockResolvedValue(true as any);
|
|
91
|
+
vi.mocked(authRepo.findUserById).mockResolvedValue({
|
|
92
|
+
id: 'user-123',
|
|
93
|
+
email: loginData.email,
|
|
94
|
+
role: 'USER',
|
|
95
|
+
} as any);
|
|
96
|
+
vi.mocked(jwt.sign).mockReturnValue('mock_token' as any);
|
|
97
|
+
vi.mocked(authRepo.createSession).mockResolvedValue({} as any);
|
|
98
|
+
|
|
99
|
+
const result = await authService.login(loginData);
|
|
100
|
+
|
|
101
|
+
expect(result).toBeDefined();
|
|
102
|
+
expect(result.user.id).toBe('user-123');
|
|
103
|
+
expect(result.tokens.accessToken).toBeDefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should throw UnauthorizedError on invalid password', async () => {
|
|
107
|
+
// Mocks
|
|
108
|
+
vi.mocked(authRepo.findUserByEmailWithPassword).mockResolvedValue({
|
|
109
|
+
id: 'user-123',
|
|
110
|
+
password: 'hashed_password',
|
|
111
|
+
} as any);
|
|
112
|
+
vi.mocked(bcrypt.compare).mockResolvedValue(false as any);
|
|
113
|
+
|
|
114
|
+
await expect(authService.login(loginData))
|
|
115
|
+
.rejects
|
|
116
|
+
.toThrow(UnauthorizedError);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|