create-tigra 1.1.0 → 2.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/LICENSE +21 -21
- package/README.md +80 -87
- package/bin/create-tigra.js +259 -308
- package/package.json +49 -41
- package/template/_claude/QUICK_REFERENCE.md +193 -0
- package/template/_claude/README.md +53 -0
- package/template/_claude/commands/create-client.md +881 -0
- package/template/_claude/commands/create-server.md +383 -0
- package/template/_claude/rules/client/01-project-structure.md +133 -0
- package/template/_claude/rules/client/02-components-and-types.md +146 -0
- package/template/_claude/rules/client/03-data-and-state.md +156 -0
- package/template/_claude/rules/client/04-design-system.md +185 -0
- package/template/_claude/rules/client/05-security.md +55 -0
- package/template/_claude/rules/client/06-ux-checklist.md +81 -0
- package/template/_claude/rules/client/core.md +42 -0
- package/template/_claude/rules/global/core.md +77 -0
- package/template/_claude/rules/server/core.md +50 -0
- package/template/_claude/rules/server/database.md +124 -0
- package/template/_claude/rules/server/project-conventions.md +150 -0
- package/template/_claude/rules/server/response-handling.md +144 -0
- package/template/client/.env.example +5 -0
- package/template/client/README.md +36 -0
- package/template/client/components.json +23 -0
- package/template/client/eslint.config.mjs +18 -0
- package/template/client/next.config.ts +34 -0
- package/template/client/package.json +44 -0
- package/template/client/postcss.config.mjs +7 -0
- package/template/client/src/app/(auth)/layout.tsx +18 -0
- package/template/client/src/app/(auth)/login/page.tsx +13 -0
- package/template/client/src/app/(auth)/register/page.tsx +13 -0
- package/template/client/src/app/(main)/dashboard/page.tsx +22 -0
- package/template/client/src/app/(main)/layout.tsx +11 -0
- package/template/client/src/app/error.tsx +27 -0
- package/template/client/src/app/favicon.ico +0 -0
- package/template/client/src/app/globals.css +145 -0
- package/template/client/src/app/layout.tsx +36 -0
- package/template/client/src/app/loading.tsx +11 -0
- package/template/client/src/app/not-found.tsx +23 -0
- package/template/client/src/app/page.tsx +45 -0
- package/template/client/src/app/providers.tsx +43 -0
- package/template/client/src/components/common/ConfirmDialog.tsx +56 -0
- package/template/client/src/components/common/EmptyState.tsx +31 -0
- package/template/client/src/components/common/LoadingSpinner.tsx +30 -0
- package/template/client/src/components/common/Pagination.tsx +55 -0
- package/template/client/src/components/layout/Footer.tsx +17 -0
- package/template/client/src/components/layout/Header.tsx +173 -0
- package/template/client/src/components/layout/MainLayout.tsx +18 -0
- package/template/client/src/components/ui/alert-dialog.tsx +196 -0
- package/template/client/src/components/ui/badge.tsx +48 -0
- package/template/client/src/components/ui/button.tsx +64 -0
- package/template/client/src/components/ui/card.tsx +92 -0
- package/template/client/src/components/ui/input.tsx +21 -0
- package/template/client/src/components/ui/label.tsx +24 -0
- package/template/client/src/components/ui/select.tsx +190 -0
- package/template/client/src/components/ui/skeleton.tsx +13 -0
- package/template/client/src/components/ui/table.tsx +116 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +55 -0
- package/template/client/src/features/auth/components/LoginForm.tsx +107 -0
- package/template/client/src/features/auth/components/RegisterForm.tsx +178 -0
- package/template/client/src/features/auth/hooks/useAuth.ts +84 -0
- package/template/client/src/features/auth/services/auth.service.ts +52 -0
- package/template/client/src/features/auth/store/authSlice.ts +38 -0
- package/template/client/src/features/auth/types/auth.types.ts +32 -0
- package/template/client/src/hooks/useDebounce.ts +14 -0
- package/template/client/src/hooks/useLocalStorage.ts +55 -0
- package/template/client/src/hooks/useMediaQuery.ts +27 -0
- package/template/client/src/lib/api/api.types.ts +34 -0
- package/template/client/src/lib/api/axios.config.ts +98 -0
- package/template/client/src/lib/constants/api-endpoints.ts +18 -0
- package/template/client/src/lib/constants/app.constants.ts +12 -0
- package/template/client/src/lib/constants/routes.ts +9 -0
- package/template/client/src/lib/utils/error.ts +32 -0
- package/template/client/src/lib/utils/format.ts +37 -0
- package/template/client/src/lib/utils/security.ts +34 -0
- package/template/client/src/lib/utils.ts +6 -0
- package/template/client/src/middleware.ts +57 -0
- package/template/client/src/store/hooks.ts +7 -0
- package/template/client/src/store/index.ts +12 -0
- package/template/client/src/types/index.ts +3 -0
- package/template/client/tsconfig.json +34 -0
- package/template/gitignore +34 -0
- package/template/server/.dockerignore +66 -0
- package/template/server/.env.example +96 -69
- package/template/server/.env.production.example +90 -0
- package/template/server/Dockerfile +94 -0
- package/template/server/docker-compose.yml +82 -111
- package/template/server/docs/logging.md +62 -0
- package/template/server/eslint.config.mjs +17 -0
- package/template/server/package.json +68 -81
- package/template/server/phpmyadmin-config.php +26 -0
- package/template/server/postman_collection.json +666 -0
- package/template/server/prisma/schema.prisma +77 -93
- package/template/server/prisma/seed.ts +46 -142
- package/template/server/scripts/flush-redis.ts +41 -0
- package/template/server/src/app.ts +243 -71
- package/template/server/src/config/env.ts +67 -94
- package/template/server/src/libs/auth.ts +88 -0
- package/template/server/src/libs/cleanup.ts +35 -0
- package/template/server/src/libs/cookies.ts +46 -0
- package/template/server/src/libs/logger.ts +33 -60
- package/template/server/src/libs/monitoring.ts +205 -0
- package/template/server/src/libs/password.ts +38 -0
- package/template/server/src/libs/prisma.ts +68 -0
- package/template/server/src/libs/redis.ts +60 -79
- package/template/server/src/libs/requestLogger.ts +66 -0
- package/template/server/src/libs/storage/file-storage.service.ts +211 -0
- package/template/server/src/libs/storage/file-validator.ts +97 -0
- package/template/server/src/libs/storage/filename-sanitizer.ts +71 -0
- package/template/server/src/libs/storage/image-optimizer.service.ts +144 -0
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +365 -0
- package/template/server/src/modules/auth/auth.controller.ts +90 -141
- package/template/server/src/modules/auth/auth.repo.ts +120 -218
- package/template/server/src/modules/auth/auth.routes.ts +96 -83
- package/template/server/src/modules/auth/auth.schemas.ts +35 -137
- package/template/server/src/modules/auth/auth.service.ts +286 -329
- package/template/server/src/modules/auth/session.repo.ts +110 -0
- package/template/server/src/modules/users/users.controller.ts +120 -0
- package/template/server/src/modules/users/users.repo.ts +77 -0
- package/template/server/src/modules/users/users.routes.ts +89 -0
- package/template/server/src/modules/users/users.schemas.ts +21 -0
- package/template/server/src/modules/users/users.service.ts +169 -0
- package/template/server/src/server.ts +58 -139
- package/template/server/src/shared/errors/AppError.ts +21 -0
- package/template/server/src/shared/errors/errors.ts +43 -0
- package/template/server/src/shared/responses/paginatedResponse.ts +38 -0
- package/template/server/src/shared/responses/successResponse.ts +17 -0
- package/template/server/src/shared/schemas/pagination.schema.ts +12 -0
- package/template/server/src/shared/types/index.ts +26 -0
- package/template/server/src/test/setup.ts +74 -38
- package/template/server/tsconfig.json +27 -89
- package/template/server/uploads/avatars/.gitkeep +1 -0
- package/template/server/vitest.config.ts +43 -98
- package/template/.agent/rules/client/01-project-structure.md +0 -326
- package/template/.agent/rules/client/02-component-patterns.md +0 -249
- package/template/.agent/rules/client/03-typescript-rules.md +0 -226
- package/template/.agent/rules/client/04-state-management.md +0 -474
- package/template/.agent/rules/client/05-api-integration.md +0 -129
- package/template/.agent/rules/client/06-forms-validation.md +0 -129
- package/template/.agent/rules/client/07-common-patterns.md +0 -150
- package/template/.agent/rules/client/08-color-system.md +0 -93
- package/template/.agent/rules/client/09-security-rules.md +0 -97
- package/template/.agent/rules/client/10-testing-strategy.md +0 -370
- package/template/.agent/rules/global/ai-edit-safety.md +0 -38
- package/template/.agent/rules/server/01-db-and-migrations.md +0 -242
- package/template/.agent/rules/server/02-general-rules.md +0 -111
- package/template/.agent/rules/server/03-migrations.md +0 -20
- package/template/.agent/rules/server/04-pagination.md +0 -130
- package/template/.agent/rules/server/05-project-conventions.md +0 -71
- package/template/.agent/rules/server/06-response-handling.md +0 -173
- package/template/.agent/rules/server/07-testing-strategy.md +0 -506
- package/template/.agent/rules/server/08-observability.md +0 -180
- package/template/.agent/rules/server/10-background-jobs-v2.md +0 -185
- package/template/.agent/rules/server/11-rate-limiting-v2.md +0 -210
- package/template/.agent/rules/server/12-performance-optimization.md +0 -567
- package/template/.claude/rules/client-01-project-structure.md +0 -327
- package/template/.claude/rules/client-02-component-patterns.md +0 -250
- package/template/.claude/rules/client-03-typescript-rules.md +0 -227
- package/template/.claude/rules/client-04-state-management.md +0 -475
- package/template/.claude/rules/client-05-api-integration.md +0 -130
- package/template/.claude/rules/client-06-forms-validation.md +0 -130
- package/template/.claude/rules/client-07-common-patterns.md +0 -151
- package/template/.claude/rules/client-08-color-system.md +0 -94
- package/template/.claude/rules/client-09-security-rules.md +0 -98
- package/template/.claude/rules/client-10-testing-strategy.md +0 -371
- package/template/.claude/rules/global-ai-edit-safety.md +0 -39
- package/template/.claude/rules/server-01-db-and-migrations.md +0 -243
- package/template/.claude/rules/server-02-general-rules.md +0 -112
- package/template/.claude/rules/server-03-migrations.md +0 -21
- package/template/.claude/rules/server-04-pagination.md +0 -131
- package/template/.claude/rules/server-05-project-conventions.md +0 -72
- package/template/.claude/rules/server-06-response-handling.md +0 -174
- package/template/.claude/rules/server-07-testing-strategy.md +0 -507
- package/template/.claude/rules/server-08-observability.md +0 -181
- package/template/.claude/rules/server-10-background-jobs-v2.md +0 -186
- package/template/.claude/rules/server-11-rate-limiting-v2.md +0 -211
- package/template/.claude/rules/server-12-performance-optimization.md +0 -568
- package/template/.cursor/rules/client-01-project-structure.mdc +0 -327
- package/template/.cursor/rules/client-02-component-patterns.mdc +0 -250
- package/template/.cursor/rules/client-03-typescript-rules.mdc +0 -227
- package/template/.cursor/rules/client-04-state-management.mdc +0 -475
- package/template/.cursor/rules/client-05-api-integration.mdc +0 -130
- package/template/.cursor/rules/client-06-forms-validation.mdc +0 -130
- package/template/.cursor/rules/client-07-common-patterns.mdc +0 -151
- package/template/.cursor/rules/client-08-color-system.mdc +0 -94
- package/template/.cursor/rules/client-09-security-rules.mdc +0 -98
- package/template/.cursor/rules/client-10-testing-strategy.mdc +0 -371
- package/template/.cursor/rules/global-ai-edit-safety.mdc +0 -39
- package/template/.cursor/rules/server-01-db-and-migrations.mdc +0 -243
- package/template/.cursor/rules/server-02-general-rules.mdc +0 -112
- package/template/.cursor/rules/server-03-migrations.mdc +0 -21
- package/template/.cursor/rules/server-04-pagination.mdc +0 -131
- package/template/.cursor/rules/server-05-project-conventions.mdc +0 -72
- package/template/.cursor/rules/server-06-response-handling.mdc +0 -174
- package/template/.cursor/rules/server-07-testing-strategy.mdc +0 -507
- package/template/.cursor/rules/server-08-observability.mdc +0 -181
- package/template/.cursor/rules/server-09-api-documentation-v2.mdc +0 -169
- package/template/.cursor/rules/server-10-background-jobs-v2.mdc +0 -186
- package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +0 -211
- package/template/.cursor/rules/server-12-performance-optimization.mdc +0 -568
- package/template/CLAUDE.md +0 -207
- package/template/server/.tsc-aliasrc.json +0 -13
- package/template/server/IMPORT_FIX_CHECKLIST.md +0 -98
- package/template/server/IMPORT_FIX_COMPLETE.md +0 -89
- package/template/server/README.md +0 -183
- package/template/server/REMAINING_IMPORT_FIXES.md +0 -150
- package/template/server/SECURITY.md +0 -190
- package/template/server/Tigra-API.postman_collection.json +0 -733
- package/template/server/biome.json +0 -42
- package/template/server/scripts/fix-all-imports.ps1 +0 -52
- package/template/server/scripts/fix-imports-reference.ps1 +0 -16
- package/template/server/scripts/fix-imports.mjs +0 -55
- package/template/server/scripts/setup-env.js +0 -50
- package/template/server/scripts/wait-for-db.js +0 -60
- package/template/server/src/hooks/request-timing.hook.ts +0 -26
- package/template/server/src/libs/auth/authenticate.middleware.ts +0 -22
- package/template/server/src/libs/auth/rbac.middleware.test.ts +0 -134
- package/template/server/src/libs/auth/rbac.middleware.ts +0 -147
- package/template/server/src/libs/db.ts +0 -76
- package/template/server/src/libs/error-handler.ts +0 -89
- package/template/server/src/libs/queue.ts +0 -79
- package/template/server/src/modules/admin/admin.controller.ts +0 -122
- package/template/server/src/modules/admin/admin.routes.ts +0 -62
- package/template/server/src/modules/admin/admin.schemas.ts +0 -35
- package/template/server/src/modules/admin/admin.service.ts +0 -167
- package/template/server/src/modules/auth/auth.integration.test.ts +0 -150
- package/template/server/src/modules/auth/auth.service.test.ts +0 -119
- package/template/server/src/modules/auth/auth.types.ts +0 -97
- package/template/server/src/modules/resources/resources.controller.ts +0 -218
- package/template/server/src/modules/resources/resources.repo.ts +0 -253
- package/template/server/src/modules/resources/resources.routes.ts +0 -116
- package/template/server/src/modules/resources/resources.schemas.ts +0 -146
- package/template/server/src/modules/resources/resources.service.ts +0 -218
- package/template/server/src/modules/resources/resources.types.ts +0 -73
- package/template/server/src/plugins/rate-limit.plugin.ts +0 -21
- package/template/server/src/plugins/security.plugin.ts +0 -21
- package/template/server/src/routes/health.routes.ts +0 -31
- package/template/server/src/types/fastify.d.ts +0 -36
- package/template/server/src/utils/errors.ts +0 -108
- package/template/server/src/utils/pagination.ts +0 -120
- package/template/server/src/utils/response.ts +0 -110
- package/template/server/src/workers/file.worker.ts +0 -106
- package/template/server/tsconfig.build.json +0 -30
- package/template/server/tsconfig.test.json +0 -22
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import * as authService from '../auth.service.js';
|
|
3
|
+
import * as authRepo from '../auth.repo.js';
|
|
4
|
+
import * as authLib from '@libs/auth.js';
|
|
5
|
+
import { hashPassword, verifyPassword } from '@libs/password.js';
|
|
6
|
+
import { ConflictError, UnauthorizedError, NotFoundError } from '@shared/errors/errors.js';
|
|
7
|
+
import { testUsers, testRefreshToken, resetMocks } from '@/test/setup.js';
|
|
8
|
+
import { sessionRepository } from '../session.repo.js';
|
|
9
|
+
|
|
10
|
+
// Mock dependencies
|
|
11
|
+
vi.mock('../auth.repo.js');
|
|
12
|
+
vi.mock('@libs/auth.js');
|
|
13
|
+
vi.mock('@libs/password.js');
|
|
14
|
+
vi.mock('../session.repo.js');
|
|
15
|
+
|
|
16
|
+
describe('Auth Service', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
resetMocks();
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('register', () => {
|
|
23
|
+
const validRegisterInput = {
|
|
24
|
+
email: 'newuser@example.com',
|
|
25
|
+
password: 'Password123!',
|
|
26
|
+
firstName: 'New',
|
|
27
|
+
lastName: 'User',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
it('should successfully register a new user', async () => {
|
|
31
|
+
// Arrange
|
|
32
|
+
const hashedPassword = '$2a$12$hashedpassword';
|
|
33
|
+
const createdUser = {
|
|
34
|
+
...testUsers.validUser,
|
|
35
|
+
email: validRegisterInput.email,
|
|
36
|
+
password: hashedPassword,
|
|
37
|
+
};
|
|
38
|
+
const accessToken = 'mock-access-token';
|
|
39
|
+
const refreshToken = 'mock-refresh-token';
|
|
40
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
41
|
+
|
|
42
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
43
|
+
vi.mocked(hashPassword).mockResolvedValue(hashedPassword);
|
|
44
|
+
vi.mocked(authRepo.createUser).mockResolvedValue(createdUser);
|
|
45
|
+
vi.mocked(authLib.signAccessToken).mockReturnValue(accessToken);
|
|
46
|
+
vi.mocked(authLib.generateRefreshToken).mockReturnValue(refreshToken);
|
|
47
|
+
vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
|
|
48
|
+
vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
|
|
49
|
+
|
|
50
|
+
// Act
|
|
51
|
+
const result = await authService.register(validRegisterInput);
|
|
52
|
+
|
|
53
|
+
// Assert
|
|
54
|
+
expect(authRepo.findUserByEmail).toHaveBeenCalledWith(validRegisterInput.email);
|
|
55
|
+
expect(hashPassword).toHaveBeenCalledWith(validRegisterInput.password);
|
|
56
|
+
expect(authRepo.createUser).toHaveBeenCalledWith({
|
|
57
|
+
email: validRegisterInput.email,
|
|
58
|
+
password: hashedPassword,
|
|
59
|
+
firstName: validRegisterInput.firstName,
|
|
60
|
+
lastName: validRegisterInput.lastName,
|
|
61
|
+
});
|
|
62
|
+
expect(authLib.signAccessToken).toHaveBeenCalledWith({
|
|
63
|
+
userId: createdUser.id,
|
|
64
|
+
role: createdUser.role,
|
|
65
|
+
});
|
|
66
|
+
expect(authLib.generateRefreshToken).toHaveBeenCalled();
|
|
67
|
+
expect(authRepo.createRefreshToken).toHaveBeenCalledWith({
|
|
68
|
+
token: refreshToken,
|
|
69
|
+
userId: createdUser.id,
|
|
70
|
+
expiresAt,
|
|
71
|
+
});
|
|
72
|
+
expect(result).toEqual({
|
|
73
|
+
user: expect.objectContaining({
|
|
74
|
+
id: createdUser.id,
|
|
75
|
+
email: createdUser.email,
|
|
76
|
+
firstName: createdUser.firstName,
|
|
77
|
+
lastName: createdUser.lastName,
|
|
78
|
+
role: createdUser.role,
|
|
79
|
+
}),
|
|
80
|
+
accessToken,
|
|
81
|
+
refreshToken,
|
|
82
|
+
});
|
|
83
|
+
expect(result.user).not.toHaveProperty('password');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should throw ConflictError if email already exists', async () => {
|
|
87
|
+
// Arrange
|
|
88
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
|
|
89
|
+
|
|
90
|
+
// Act & Assert
|
|
91
|
+
await expect(authService.register(validRegisterInput)).rejects.toThrow(ConflictError);
|
|
92
|
+
await expect(authService.register(validRegisterInput)).rejects.toThrow('Email already registered');
|
|
93
|
+
expect(hashPassword).not.toHaveBeenCalled();
|
|
94
|
+
expect(authRepo.createUser).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('login', () => {
|
|
99
|
+
const validLoginInput = {
|
|
100
|
+
email: 'test@example.com',
|
|
101
|
+
password: 'Password123!',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
it('should successfully login with valid credentials', async () => {
|
|
105
|
+
// Arrange
|
|
106
|
+
const accessToken = 'mock-access-token';
|
|
107
|
+
const refreshToken = 'mock-refresh-token';
|
|
108
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
109
|
+
|
|
110
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
|
|
111
|
+
vi.mocked(verifyPassword).mockResolvedValue({ valid: true, needsRehash: false });
|
|
112
|
+
vi.mocked(authLib.signAccessToken).mockReturnValue(accessToken);
|
|
113
|
+
vi.mocked(authLib.generateRefreshToken).mockReturnValue(refreshToken);
|
|
114
|
+
vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
|
|
115
|
+
vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
|
|
116
|
+
|
|
117
|
+
// Act
|
|
118
|
+
const result = await authService.login(validLoginInput);
|
|
119
|
+
|
|
120
|
+
// Assert
|
|
121
|
+
expect(authRepo.findUserByEmail).toHaveBeenCalledWith(validLoginInput.email);
|
|
122
|
+
expect(verifyPassword).toHaveBeenCalledWith(validLoginInput.password, testUsers.validUser.password);
|
|
123
|
+
expect(result).toEqual({
|
|
124
|
+
user: expect.objectContaining({
|
|
125
|
+
id: testUsers.validUser.id,
|
|
126
|
+
email: testUsers.validUser.email,
|
|
127
|
+
}),
|
|
128
|
+
accessToken,
|
|
129
|
+
refreshToken,
|
|
130
|
+
});
|
|
131
|
+
expect(result.user).not.toHaveProperty('password');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should throw UnauthorizedError if user not found', async () => {
|
|
135
|
+
// Arrange
|
|
136
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
137
|
+
|
|
138
|
+
// Act & Assert
|
|
139
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
|
|
140
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
|
|
141
|
+
expect(verifyPassword).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should throw UnauthorizedError if account is disabled', async () => {
|
|
145
|
+
// Arrange
|
|
146
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.inactiveUser);
|
|
147
|
+
|
|
148
|
+
// Act & Assert
|
|
149
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
|
|
150
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
|
|
151
|
+
expect(verifyPassword).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should throw UnauthorizedError if password is invalid', async () => {
|
|
155
|
+
// Arrange
|
|
156
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
|
|
157
|
+
vi.mocked(verifyPassword).mockResolvedValue({ valid: false, needsRehash: false });
|
|
158
|
+
|
|
159
|
+
// Act & Assert
|
|
160
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
|
|
161
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('refresh', () => {
|
|
166
|
+
const validRefreshToken = 'valid-refresh-token';
|
|
167
|
+
|
|
168
|
+
it('should successfully refresh tokens with valid refresh token', async () => {
|
|
169
|
+
// Arrange
|
|
170
|
+
const newAccessToken = 'new-access-token';
|
|
171
|
+
const newRefreshToken = 'new-refresh-token';
|
|
172
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
173
|
+
const storedToken = {
|
|
174
|
+
...testRefreshToken,
|
|
175
|
+
token: validRefreshToken,
|
|
176
|
+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // expires in 1 day
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
|
|
180
|
+
vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.validUser);
|
|
181
|
+
vi.mocked(authLib.signAccessToken).mockReturnValue(newAccessToken);
|
|
182
|
+
vi.mocked(authLib.generateRefreshToken).mockReturnValue(newRefreshToken);
|
|
183
|
+
vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
|
|
184
|
+
vi.mocked(authRepo.rotateRefreshToken).mockResolvedValue(true);
|
|
185
|
+
|
|
186
|
+
// Act
|
|
187
|
+
const result = await authService.refresh(validRefreshToken);
|
|
188
|
+
|
|
189
|
+
// Assert
|
|
190
|
+
expect(authRepo.findRefreshToken).toHaveBeenCalledWith(validRefreshToken);
|
|
191
|
+
expect(authRepo.findUserById).toHaveBeenCalledWith(storedToken.userId);
|
|
192
|
+
expect(authLib.signAccessToken).toHaveBeenCalledWith({
|
|
193
|
+
userId: testUsers.validUser.id,
|
|
194
|
+
role: testUsers.validUser.role,
|
|
195
|
+
});
|
|
196
|
+
expect(authRepo.rotateRefreshToken).toHaveBeenCalledWith(validRefreshToken, {
|
|
197
|
+
token: newRefreshToken,
|
|
198
|
+
userId: testUsers.validUser.id,
|
|
199
|
+
expiresAt,
|
|
200
|
+
});
|
|
201
|
+
expect(result).toEqual({
|
|
202
|
+
accessToken: newAccessToken,
|
|
203
|
+
refreshToken: newRefreshToken,
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should throw UnauthorizedError if refresh token not found', async () => {
|
|
208
|
+
// Arrange
|
|
209
|
+
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(null);
|
|
210
|
+
|
|
211
|
+
// Act & Assert
|
|
212
|
+
await expect(authService.refresh(validRefreshToken)).rejects.toThrow(UnauthorizedError);
|
|
213
|
+
await expect(authService.refresh(validRefreshToken)).rejects.toThrow('Invalid refresh token');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should throw UnauthorizedError if refresh token expired', async () => {
|
|
217
|
+
// Arrange
|
|
218
|
+
const expiredToken = {
|
|
219
|
+
...testRefreshToken,
|
|
220
|
+
expiresAt: new Date(Date.now() - 1000), // expired 1 second ago
|
|
221
|
+
};
|
|
222
|
+
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(expiredToken);
|
|
223
|
+
vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
|
|
224
|
+
|
|
225
|
+
// Act & Assert
|
|
226
|
+
await expect(authService.refresh(validRefreshToken)).rejects.toThrow(UnauthorizedError);
|
|
227
|
+
await expect(authService.refresh(validRefreshToken)).rejects.toThrow('Refresh token expired');
|
|
228
|
+
expect(authRepo.deleteRefreshToken).toHaveBeenCalledWith(validRefreshToken);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should throw UnauthorizedError if user not found', async () => {
|
|
232
|
+
// Arrange
|
|
233
|
+
const storedToken = {
|
|
234
|
+
...testRefreshToken,
|
|
235
|
+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
236
|
+
};
|
|
237
|
+
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
|
|
238
|
+
vi.mocked(authRepo.findUserById).mockResolvedValue(null);
|
|
239
|
+
|
|
240
|
+
// Act & Assert
|
|
241
|
+
await expect(authService.refresh(validRefreshToken)).rejects.toThrow(UnauthorizedError);
|
|
242
|
+
await expect(authService.refresh(validRefreshToken)).rejects.toThrow('User not found or disabled');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should throw UnauthorizedError if user is disabled', async () => {
|
|
246
|
+
// Arrange
|
|
247
|
+
const storedToken = {
|
|
248
|
+
...testRefreshToken,
|
|
249
|
+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
250
|
+
};
|
|
251
|
+
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
|
|
252
|
+
vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.inactiveUser);
|
|
253
|
+
|
|
254
|
+
// Act & Assert
|
|
255
|
+
await expect(authService.refresh(validRefreshToken)).rejects.toThrow(UnauthorizedError);
|
|
256
|
+
await expect(authService.refresh(validRefreshToken)).rejects.toThrow('User not found or disabled');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('logout', () => {
|
|
261
|
+
it('should delete refresh token and matching session', async () => {
|
|
262
|
+
// Arrange
|
|
263
|
+
const refreshToken = 'valid-refresh-token';
|
|
264
|
+
const tokenCreatedAt = new Date('2024-06-01T12:00:00Z');
|
|
265
|
+
const storedToken = {
|
|
266
|
+
...testRefreshToken,
|
|
267
|
+
token: refreshToken,
|
|
268
|
+
createdAt: tokenCreatedAt,
|
|
269
|
+
};
|
|
270
|
+
const matchingSession = {
|
|
271
|
+
id: 'session-1',
|
|
272
|
+
userId: testUsers.validUser.id,
|
|
273
|
+
deviceInfo: 'test-agent',
|
|
274
|
+
ipAddress: '127.0.0.1',
|
|
275
|
+
lastActiveAt: new Date(),
|
|
276
|
+
expiresAt: new Date(Date.now() + 86400000),
|
|
277
|
+
createdAt: new Date('2024-06-01T12:00:00.100Z'), // within 5s of token
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
|
|
281
|
+
vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
|
|
282
|
+
vi.mocked(sessionRepository.getUserSessions).mockResolvedValue([matchingSession]);
|
|
283
|
+
vi.mocked(sessionRepository.deleteSession).mockResolvedValue(undefined as never);
|
|
284
|
+
|
|
285
|
+
// Act
|
|
286
|
+
await authService.logout(refreshToken);
|
|
287
|
+
|
|
288
|
+
// Assert
|
|
289
|
+
expect(authRepo.findRefreshToken).toHaveBeenCalledWith(refreshToken);
|
|
290
|
+
expect(authRepo.deleteRefreshToken).toHaveBeenCalledWith(refreshToken);
|
|
291
|
+
expect(sessionRepository.getUserSessions).toHaveBeenCalledWith(storedToken.userId);
|
|
292
|
+
expect(sessionRepository.deleteSession).toHaveBeenCalledWith(matchingSession.id);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should not delete session if no matching createdAt found', async () => {
|
|
296
|
+
// Arrange
|
|
297
|
+
const refreshToken = 'valid-refresh-token';
|
|
298
|
+
const storedToken = {
|
|
299
|
+
...testRefreshToken,
|
|
300
|
+
token: refreshToken,
|
|
301
|
+
createdAt: new Date('2024-06-01T12:00:00Z'),
|
|
302
|
+
};
|
|
303
|
+
const unmatchedSession = {
|
|
304
|
+
id: 'session-2',
|
|
305
|
+
userId: testUsers.validUser.id,
|
|
306
|
+
deviceInfo: null,
|
|
307
|
+
ipAddress: null,
|
|
308
|
+
lastActiveAt: new Date(),
|
|
309
|
+
expiresAt: new Date(Date.now() + 86400000),
|
|
310
|
+
createdAt: new Date('2024-05-01T00:00:00Z'), // way off — no match
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
|
|
314
|
+
vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
|
|
315
|
+
vi.mocked(sessionRepository.getUserSessions).mockResolvedValue([unmatchedSession]);
|
|
316
|
+
|
|
317
|
+
// Act
|
|
318
|
+
await authService.logout(refreshToken);
|
|
319
|
+
|
|
320
|
+
// Assert
|
|
321
|
+
expect(sessionRepository.deleteSession).not.toHaveBeenCalled();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should not throw error if token deletion fails', async () => {
|
|
325
|
+
// Arrange
|
|
326
|
+
const refreshToken = 'invalid-refresh-token';
|
|
327
|
+
vi.mocked(authRepo.findRefreshToken).mockRejectedValue(new Error('DB error'));
|
|
328
|
+
|
|
329
|
+
// Act & Assert
|
|
330
|
+
await expect(authService.logout(refreshToken)).resolves.not.toThrow();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('getCurrentUser', () => {
|
|
335
|
+
it('should return sanitized user data', async () => {
|
|
336
|
+
// Arrange
|
|
337
|
+
vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.validUser);
|
|
338
|
+
|
|
339
|
+
// Act
|
|
340
|
+
const result = await authService.getCurrentUser(testUsers.validUser.id);
|
|
341
|
+
|
|
342
|
+
// Assert
|
|
343
|
+
expect(authRepo.findUserById).toHaveBeenCalledWith(testUsers.validUser.id);
|
|
344
|
+
expect(result).toEqual(
|
|
345
|
+
expect.objectContaining({
|
|
346
|
+
id: testUsers.validUser.id,
|
|
347
|
+
email: testUsers.validUser.email,
|
|
348
|
+
firstName: testUsers.validUser.firstName,
|
|
349
|
+
lastName: testUsers.validUser.lastName,
|
|
350
|
+
role: testUsers.validUser.role,
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
expect(result).not.toHaveProperty('password');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should throw NotFoundError if user not found', async () => {
|
|
357
|
+
// Arrange
|
|
358
|
+
vi.mocked(authRepo.findUserById).mockResolvedValue(null);
|
|
359
|
+
|
|
360
|
+
// Act & Assert
|
|
361
|
+
await expect(authService.getCurrentUser('invalid-user-id')).rejects.toThrow(NotFoundError);
|
|
362
|
+
await expect(authService.getCurrentUser('invalid-user-id')).rejects.toThrow('User not found');
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
});
|
|
@@ -1,141 +1,90 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
export async function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
): Promise<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
* Logout user
|
|
92
|
-
*
|
|
93
|
-
* @route POST /auth/logout
|
|
94
|
-
* @access Public
|
|
95
|
-
*/
|
|
96
|
-
export async function logout(
|
|
97
|
-
request: FastifyRequest<{ Body: RefreshTokenInput }>,
|
|
98
|
-
reply: FastifyReply
|
|
99
|
-
): Promise<FastifyReply> {
|
|
100
|
-
// Validate request body
|
|
101
|
-
const body = RefreshTokenSchema.parse(request.body);
|
|
102
|
-
|
|
103
|
-
// Call service
|
|
104
|
-
await authService.logout(body.refreshToken);
|
|
105
|
-
|
|
106
|
-
// Return success response
|
|
107
|
-
return reply.status(200).send(
|
|
108
|
-
successResponse('Logout successful', null)
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Get current authenticated user
|
|
114
|
-
*
|
|
115
|
-
* @route GET /auth/me
|
|
116
|
-
* @access Private (requires authentication)
|
|
117
|
-
*/
|
|
118
|
-
export async function getMe(
|
|
119
|
-
request: FastifyRequest,
|
|
120
|
-
reply: FastifyReply
|
|
121
|
-
): Promise<FastifyReply> {
|
|
122
|
-
// Get user ID from authenticated request
|
|
123
|
-
// Note: request.user is set by authentication middleware
|
|
124
|
-
const userId = (request.user as any)?.userId;
|
|
125
|
-
|
|
126
|
-
if (!userId) {
|
|
127
|
-
throw new NotFoundError('User not found');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Find user by ID
|
|
131
|
-
const user = await authRepo.findUserById(userId);
|
|
132
|
-
|
|
133
|
-
if (!user) {
|
|
134
|
-
throw new NotFoundError('User not found');
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Return success response
|
|
138
|
-
return reply.status(200).send(
|
|
139
|
-
successResponse('User retrieved successfully', user)
|
|
140
|
-
);
|
|
141
|
-
}
|
|
1
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import { successResponse } from '@shared/responses/successResponse.js';
|
|
3
|
+
import { UnauthorizedError } from '@shared/errors/errors.js';
|
|
4
|
+
import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
|
|
5
|
+
import type {
|
|
6
|
+
RegisterInput,
|
|
7
|
+
LoginInput,
|
|
8
|
+
} from './auth.schemas.js';
|
|
9
|
+
import * as authService from './auth.service.js';
|
|
10
|
+
|
|
11
|
+
export async function register(
|
|
12
|
+
request: FastifyRequest<{ Body: RegisterInput }>,
|
|
13
|
+
reply: FastifyReply,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
const deviceInfo = request.headers['user-agent'];
|
|
16
|
+
const ipAddress = request.ip;
|
|
17
|
+
|
|
18
|
+
const result = await authService.register(request.body, deviceInfo, ipAddress);
|
|
19
|
+
|
|
20
|
+
setAuthCookies(reply, result.accessToken, result.refreshToken);
|
|
21
|
+
reply.status(201).send(successResponse('User registered successfully', { user: result.user }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function login(
|
|
25
|
+
request: FastifyRequest<{ Body: LoginInput }>,
|
|
26
|
+
reply: FastifyReply,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const deviceInfo = request.headers['user-agent'];
|
|
29
|
+
const ipAddress = request.ip;
|
|
30
|
+
|
|
31
|
+
const result = await authService.login(request.body, deviceInfo, ipAddress);
|
|
32
|
+
|
|
33
|
+
setAuthCookies(reply, result.accessToken, result.refreshToken);
|
|
34
|
+
reply.send(successResponse('Logged in successfully', { user: result.user }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function refresh(
|
|
38
|
+
request: FastifyRequest,
|
|
39
|
+
reply: FastifyReply,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const refreshToken = request.cookies.refresh_token;
|
|
42
|
+
if (!refreshToken) {
|
|
43
|
+
throw new UnauthorizedError('Refresh token not provided', 'MISSING_REFRESH_TOKEN');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tokens = await authService.refresh(refreshToken);
|
|
47
|
+
|
|
48
|
+
setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
|
|
49
|
+
reply.send(successResponse('Token refreshed successfully', null));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function logout(
|
|
53
|
+
request: FastifyRequest,
|
|
54
|
+
reply: FastifyReply,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const refreshToken = request.cookies.refresh_token;
|
|
57
|
+
if (refreshToken) {
|
|
58
|
+
await authService.logout(refreshToken);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
clearAuthCookies(reply);
|
|
62
|
+
reply.send(successResponse('Logged out successfully', null));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function me(
|
|
66
|
+
request: FastifyRequest,
|
|
67
|
+
reply: FastifyReply,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const user = await authService.getCurrentUser(request.user.userId);
|
|
70
|
+
reply.send(successResponse('Current user retrieved', user));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function getSessions(
|
|
74
|
+
request: FastifyRequest,
|
|
75
|
+
reply: FastifyReply,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const sessions = await authService.getUserSessions(request.user.userId);
|
|
78
|
+
reply.send(successResponse('User sessions retrieved', sessions));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function logoutAllSessions(
|
|
82
|
+
request: FastifyRequest,
|
|
83
|
+
reply: FastifyReply,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
const count = await authService.logoutAllSessions(request.user.userId);
|
|
86
|
+
clearAuthCookies(reply);
|
|
87
|
+
reply.send(
|
|
88
|
+
successResponse(`Successfully logged out from ${count} session(s)`, { count }),
|
|
89
|
+
);
|
|
90
|
+
}
|