diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..f928517 --- /dev/null +++ b/.env.test @@ -0,0 +1,14 @@ +NODE_ENV=development +PORT=3000 +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=treeo2 +DB_USER=treeo2_user +DB_PASSWORD=treeo2_password +DATABASE_URL=postgresql://treeo2_user:treeo2_password@localhost:5432/treeo2?schema=public +JWT_SECRET=test-secret-key-minimum-32-characters-long +JWT_EXPIRES_IN=24h +AUTH_DEV_MODE=false +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 +LOG_TO_FILE=false \ No newline at end of file diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..bc6c3fe --- /dev/null +++ b/.env.test.example @@ -0,0 +1,14 @@ +NODE_ENV=development +PORT=3000 +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=your-db-name +DB_USER=your-db-user +DB_PASSWORD=your-db-password +DATABASE_URL=postgresql://your-db-user:your-db-password@localhost:5432/your-db-name?schema=public +JWT_SECRET=your-test-secret-key-minimum-32-characters-long +JWT_EXPIRES_IN=24h +AUTH_DEV_MODE=false +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 +LOG_TO_FILE=false \ No newline at end of file diff --git a/.gitignore b/.gitignore index d53742e..cf8e244 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ logs/ coverage/ .DS_Store +!.env.test.example \ No newline at end of file diff --git a/docs/AuthModule.md b/docs/AuthModule.md index d839a31..3b94e79 100644 --- a/docs/AuthModule.md +++ b/docs/AuthModule.md @@ -5,6 +5,8 @@ This document tracks the current structure and implementation state of the authentication and authorization module. It is intended to be updated as the auth APIs are implemented over time, including: + +- register - login - logout - forgot-password @@ -17,9 +19,21 @@ It is intended to be updated as the auth APIs are implemented over time, includi ## Current Status -The auth module is currently **scaffolded only**. +The auth module is currently **partially implemented**. + +What is **fully implemented:** + +- user registration (`POST /auth/register`) +- request validation schemas (register, login, forgot-password, reset-password) +- auth-specific types and interfaces +- Swagger documentation for register endpoint +- JWT helper +- bcrypt helper (`hashPassword()` wired into registration) +- auth/role/project-scope/security middleware scaffolding +- `requestId` included in all error responses What exists now: + - auth routes - auth controller - auth service @@ -32,6 +46,7 @@ What exists now: - auth/role/project-scope/security middleware scaffolding What is **not** implemented yet: + - real login - real logout/session invalidation - forgot-password flow @@ -40,10 +55,14 @@ What is **not** implemented yet: - Prisma-backed user and role queries - password verification - JWT issuance in live auth flow +- DB session checks in auth middleware +- DB-backed role and project scope enforcement +- password verification on login All unfinished auth service methods currently return `501 Not Implemented` safely. Temporary development support: + - a development-only auth mode is available through `auth.middleware.ts` - when `NODE_ENV=development` and `AUTH_DEV_MODE=true`, fixed local bearer tokens can be used for protected route development - this is intended only to unblock API development until real auth is implemented @@ -82,11 +101,14 @@ src/ ### `src/modules/auth/auth.routes.ts` Contains: + - route definitions for auth endpoints - route-level validation middleware - auth middleware on protected auth routes Current endpoints: + +- `POST /auth/register` - implemented - `POST /auth/login` - `POST /auth/logout` - `POST /auth/forgot-password` @@ -99,22 +121,29 @@ Current endpoints: ### `src/modules/auth/auth.controller.ts` Contains: + - request/response handling for auth endpoints - calls into the auth service Current state: -- delegates to the service layer + +- `register()` — fully implemented, returns `201` with user object +- all other methods — scaffold only, return `501 Not Implemented` - no real auth response logic yet ### `src/modules/auth/auth.service.ts` Contains: + - auth business-logic layer Current state: +`register()` — fully implemented with duplicate email check, role lookup, bcrypt hashing, and safe response + - methods intentionally throw `501 Not Implemented` Future responsibility: + - credential verification - JWT creation - user lookup @@ -124,44 +153,63 @@ Future responsibility: ### `src/modules/auth/auth.repository.ts` Contains: + - auth-related database access layer - Prisma access point for auth queries +Current state: + +- `findUserByEmail()` — implemented +- `findRoleByName()` — implemented +- `createUser()` — implemented + Future responsibility: -- find user by email/id + +- find user by email/ID - fetch roles - update password hash -- store/reset tokens if needed +- update access token on login +- clear access token on logout +- store and verify reset tokens ### `src/modules/auth/auth.schemas.ts` Contains: + - Zod validation schemas for auth requests Current schemas: -- login -- forgot-password -- reset-password + +- `registerSchema` — name, email, password (with complexity rules), role +- `loginSchema` +- `forgotPasswordSchema` +- `resetPasswordSchema` ### `src/modules/auth/auth.types.ts` Contains: + - auth-specific TypeScript types - string role names - JWT payload type -- request body types +- request body types including `RegisterRequestBody` and `RegisterResponse` ### `src/modules/auth/auth.docs.ts` Contains: -- Swagger/OpenAPI placeholder documentation for auth endpoints + +- Swagger/OpenAPI documentation for all auth endpoints +- full request/response schema for `POST /auth/register` +- placeholder docs for all other endpoints ### `src/modules/auth/index.ts` Contains: + - single public entry point export for the auth module Purpose: + - keeps module imports clean and consistent with the agreed project convention --- @@ -171,11 +219,13 @@ Purpose: ### `src/middleware/auth.middleware.ts` Purpose: + - validates bearer token presence - verifies JWT - attaches payload to `req.user` Current temporary development support: + - if `AUTH_DEV_MODE=true` and the app is running in development mode - the middleware accepts fixed local dev tokens: - `dev-admin-token` @@ -189,24 +239,29 @@ Current temporary development support: ### `src/middleware/role.middleware.ts` Purpose: + - checks whether the authenticated user has one of the allowed roles ### `src/middleware/projectScope.middleware.ts` Purpose: + - placeholder for project-scoped authorization - currently reads `x-project-id` and stores it on `req.projectScope` ### `src/middleware/validate.middleware.ts` Purpose: + - validates request body/query/params using Zod ### `src/middleware/securityAudit.middleware.ts` Purpose: -- adds `requestId` + +- generates a unique `requestId` for every request - logs security-relevant request details +- `requestId` is included in all error responses for traceability --- @@ -215,9 +270,11 @@ Purpose: ### `src/lib/jwt.ts` Purpose: + - central JWT sign/verify helper Current state: + - scaffold helper is ready - verification is usable - real token issuance is not wired into auth flow yet @@ -225,9 +282,13 @@ Current state: ### `src/lib/bcrypt.ts` Purpose: + - password hash and compare helper Current state: + +- `hashPassword()` — wired into registration flow +- `comparePassword()` — not yet connected to login flow - helper exists - not yet connected to real login/reset flows @@ -235,11 +296,25 @@ Current state: ## Current Request Flow +### Register + +`POST /auth/register` + +Flow: + +1. request body validated with Zod (registerSchema) +2. duplicate email check via repository +3. role lookup via repository +4. password hashed with bcrypt +5. user created in DB +6. safe response returned (no password hash) + ### Login `POST /auth/login` Flow: + 1. request reaches auth route 2. request body is validated with Zod 3. controller calls service @@ -251,6 +326,7 @@ Flow: `POST /auth/logout` Flow: + 1. request reaches auth route 2. `auth.middleware.ts` verifies JWT 3. controller calls service @@ -262,6 +338,7 @@ Flow: `POST /auth/forgot-password` Flow: + 1. request body is validated 2. controller calls service 3. service throws `501` @@ -272,6 +349,7 @@ Flow: `POST /auth/reset-password` Flow: + 1. request body is validated 2. controller calls service 3. service throws `501` @@ -282,6 +360,7 @@ Flow: `GET /auth/me` Flow: + 1. bearer token is verified 2. JWT payload is attached to `req.user` 3. controller calls service @@ -295,10 +374,12 @@ Flow: This repository currently supports a temporary local auth mode to allow protected API development before full auth is implemented. Required conditions: + - `NODE_ENV=development` - `AUTH_DEV_MODE=true` Supported local bearer tokens: + - `Bearer dev-admin-token` - `Bearer dev-farmer-token` - `Bearer dev-manager-token` @@ -306,11 +387,13 @@ Supported local bearer tokens: - `Bearer dev-developer-token` Purpose: + - allow API teams to continue testing protected endpoints - allow role middleware testing before real login is implemented - avoid adding unsafe fake login endpoints Important: + - this is local development support only - it must not be treated as the final authentication implementation - when `AUTH_DEV_MODE=false`, the dev tokens must be rejected and normal JWT verification must apply @@ -324,9 +407,11 @@ The auth module currently includes protected test endpoints to verify middleware ### `GET /auth/test/protected` Purpose: + - verify basic auth middleware behavior Expected behavior: + - no token -> `401` - invalid token -> `401` - valid dev token with `AUTH_DEV_MODE=true` -> `200` @@ -334,9 +419,11 @@ Expected behavior: ### `GET /auth/test/admin` Purpose: + - verify role middleware behavior Expected behavior: + - no token -> `401` - non-admin authenticated user -> `403` - admin dev token with `AUTH_DEV_MODE=true` -> `200` @@ -344,18 +431,69 @@ Expected behavior: ### `GET /auth/test/project-scope` Purpose: + - verify project-scope middleware behavior Expected behavior: + - no token -> `401` - missing or invalid `x-project-id` -> `403` - valid authenticated user plus valid `x-project-id` -> `200` --- +## Registration API Implementation + +### What was implemented + +`POST /auth/register` — fully implemented and tested. + +**Files modified:** + +- `auth.types.ts` — added `RegisterRequestBody` and `RegisterResponse` interfaces +- `auth.schemas.ts` — added `registerSchema` with Zod validation +- `auth.repository.ts` — added `findUserByEmail()`, `findRoleByName()`, `createUser()` +- `auth.service.ts` — added `register()` with full business logic +- `auth.controller.ts` — added `register()` controller method +- `auth.routes.ts` — added `POST /auth/register` route +- `auth.docs.ts` — added Swagger documentation for register endpoint +- `errorHandler.ts` — added `requestId` to all error responses + +### Test Coverage + +**Unit tests** — `tests/unit/auth.test.ts` + +- Successful registration +- Duplicate email → 409 +- Role not found → 400 +- Password hash not in response + +**Integration tests** — `tests/integration/auth.test.ts` + +- Valid registration → 201 +- Missing fields → 400 +- Weak password → 400 +- Invalid role → 400 +- Duplicate email → 409 +- Password not exposed in response + +### Test Environment Setup + +| File | Purpose | +| -------------------- | -------------------------------------------------------------------- | +| `tests/setup.ts` | Sets all required environment variables before each Jest worker runs | +| `tsconfig.test.json` | TypeScript config that includes test files and Jest types | +| `jest.config.js` | Updated to wire setup file and test tsconfig | +| `.env.test.example` | Template for teammates to configure local test environment | + +### Database Dependency + +## The `roles` table must be seeded before registration works. + ## Planned Next Updates This document should be updated when we implement: + - Prisma-backed auth repository queries - real login flow - password hashing checks @@ -365,3 +503,6 @@ This document should be updated when we implement: - role lookup from Prisma role model - project-scoped authorization rules - auth API request/response examples +- DB session checks in auth middleware +- DB-backed role and project scope enforcement +- current-user (`me`) endpoint diff --git a/docs/RegisterAPI.md b/docs/RegisterAPI.md new file mode 100644 index 0000000..5d009b4 --- /dev/null +++ b/docs/RegisterAPI.md @@ -0,0 +1,73 @@ +# User Registration API + +**Endpoint:** `POST /auth/register` + +--- + +## Request Body + +```json +{ + "name": "John Farmer", + "email": "john@treeo2.com", + "password": "Secure@1234", + "role": "FARMER" +} +``` + +| Field | Type | Rules | +| -------- | ------ | ---------------------------------------------------------------- | +| name | string | Min 1, Max 100 | +| email | string | Valid email, Max 300, unique | +| password | string | Min 8, Max 72, must include uppercase, number, special character | +| role | string | FARMER, INSPECTOR, MANAGER, ADMIN, DEVELOPER | + +--- + +## Responses + +| Code | Description | +| ---- | ---------------------------- | +| 201 | User registered successfully | +| 400 | Validation failed | +| 409 | Email already exists | + +--- + +## Flow + +1. Validate request body (Zod) +2. Check for duplicate email +3. Verify role exists in DB +4. Hash password (bcrypt) +5. Create user in DB +6. Return safe user response (no password hash) + +--- + +## Tests + +**Unit tests** — `tests/unit/auth.test.ts` + +- Successful registration +- Duplicate email → 409 +- Role not found → 400 +- Password hash not in response + +**Integration tests** — `tests/integration/auth.test.ts` + +- Valid registration → 201 +- Missing fields → 400 +- Weak password → 400 +- Invalid role → 400 +- Duplicate email → 409 +- Password not exposed in response + +--- + +## Notes + +- Roles table must be seeded before registration works +- Password is hashed using bcrypt and never returned in any response +- All error responses include a `requestId` for traceability +- Swagger docs available at `http://localhost:3000/api-docs` diff --git a/jest.config.js b/jest.config.js index 7d5904f..a66a4ee 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,9 +1,15 @@ /** @type {import('jest').Config} */ module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - roots: ['/tests'], - testMatch: ['**/*.test.ts'], - collectCoverageFrom: ['src/**/*.ts', '!src/index.ts'], - coverageDirectory: 'coverage', + preset: "ts-jest", + testEnvironment: "node", + roots: ["/tests"], + testMatch: ["**/*.test.ts"], + collectCoverageFrom: ["src/**/*.ts", "!src/index.ts"], + coverageDirectory: "coverage", + setupFiles: ["/tests/setup.ts"], + globals: { + "ts-jest": { + tsconfig: "tsconfig.test.json", + }, + }, }; diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index 0d06ad0..c5bde3b 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -25,6 +25,7 @@ export const errorHandler = ( stack: err.stack, url: req.url, method: req.method, + requestId: req.requestId ?? null, }); if (err instanceof AppError) { @@ -32,6 +33,7 @@ export const errorHandler = ( success: false, message: err.message, code: err.code, + requestId: req.requestId ?? null, }); return; } @@ -41,19 +43,32 @@ export const errorHandler = ( success: false, message: ERROR_CODES.VAL_001, errors: err.flatten().fieldErrors, + requestId: req.requestId ?? null, }); return; } // Postgres unique violation if ((err as NodeJS.ErrnoException).code === "23505") { - res.status(409).json({ success: false, message: ERROR_CODES.DATA_002 }); + res.status(409).json({ + success: false, + message: ERROR_CODES.DATA_002, + requestId: req.requestId ?? null, + }); return; } - res.status(500).json({ success: false, message: ERROR_CODES.SYS_001 }); + res.status(500).json({ + success: false, + message: ERROR_CODES.SYS_001, + requestId: req.requestId ?? null, + }); }; export const notFound = (req: Request, res: Response): void => { - res.status(404).json({ success: false, message: ERROR_CODES.DATA_001 }); + res.status(404).json({ + success: false, + message: ERROR_CODES.DATA_001, + requestId: req.requestId ?? null, + }); }; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index bdf7e85..b2645a5 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -6,6 +6,7 @@ import type { ForgotPasswordRequestBody, JwtPayload, LoginRequestBody, + RegisterRequestBody, ResetPasswordRequestBody, } from "./auth.types"; @@ -39,9 +40,15 @@ export class AuthController { res.status(501).json({ success: false, message: "Not implemented" }); } + async register(req: Request, res: Response): Promise { + const result = await this.authService.register( + req.body as RegisterRequestBody, + ); + res.status(201).json(result); + } + getProtectedTest(req: Request, res: Response): void { const user = this.requireUser(req); - res.status(200).json({ success: true, message: "Protected auth test endpoint reached", @@ -54,7 +61,6 @@ export class AuthController { getAdminTest(req: Request, res: Response): void { const user = this.requireUser(req); - res.status(200).json({ success: true, message: "Admin auth test endpoint reached", @@ -67,7 +73,6 @@ export class AuthController { getProjectScopeTest(req: Request, res: Response): void { const user = this.requireUser(req); - res.status(200).json({ success: true, message: "Project scope test endpoint reached", diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index 9e5e5a3..be17586 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -18,6 +18,75 @@ * description: Not implemented */ +/** + * @swagger + * /auth/register: + * post: + * summary: Register a new user + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - email + * - password + * - role + * properties: + * name: + * type: string + * maxLength: 100 + * example: Test User + * email: + * type: string + * format: email + * maxLength: 300 + * example: test@treeo2.com + * password: + * type: string + * minLength: 8 + * maxLength: 72 + * example: Test@1234 + * role: + * type: string + * enum: [FARMER, INSPECTOR, MANAGER, ADMIN, DEVELOPER] + * example: FARMER + * responses: + * 201: + * description: User registered successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * user: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * name: + * type: string + * example: Test User + * email: + * type: string + * example: test@treeo2.com + * role: + * type: string + * example: FARMER + * 400: + * description: Validation failed + * 409: + * description: Email already exists + */ +/** + /** * @swagger * /auth/logout: diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts index a260466..abb2c25 100644 --- a/src/modules/auth/auth.repository.ts +++ b/src/modules/auth/auth.repository.ts @@ -1,8 +1,32 @@ import { prisma } from "../../lib/prisma"; export class AuthRepository { - getPrismaClient() { - return prisma; + async findUserByEmail(email: string) { + return prisma.user.findUnique({ + where: { email }, + }); + } + + async findRoleByName(name: string) { + return prisma.role.findUnique({ + where: { name }, + }); + } + + async createUser(data: { + name: string; + email: string; + passwordHash: string; + roleId: number; + }) { + return prisma.user.create({ + data: { + name: data.name, + email: data.email, + passwordHash: data.passwordHash, + roleId: data.roleId, + }, + }); } getRoleModelAvailability(): boolean { diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts index c180d57..a387e29 100644 --- a/src/modules/auth/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -9,6 +9,7 @@ import "./auth.docs"; import { forgotPasswordSchema, loginSchema, + registerSchema, resetPasswordSchema, } from "./auth.schemas"; @@ -19,6 +20,14 @@ router.post("/login", validateMiddleware(loginSchema), (req, res, next) => { void authController.login(req, res).catch(next); }); +router.post( + "/register", + validateMiddleware(registerSchema), + (req, res, next) => { + void authController.register(req, res).catch(next); + }, +); + router.post("/logout", authMiddleware, (req, res, next) => { void authController.logout(req, res).catch(next); }); diff --git a/src/modules/auth/auth.schemas.ts b/src/modules/auth/auth.schemas.ts index ed61e24..5aa1338 100644 --- a/src/modules/auth/auth.schemas.ts +++ b/src/modules/auth/auth.schemas.ts @@ -19,3 +19,18 @@ export const resetPasswordSchema = z.object({ password: z.string().min(8), }), }); + +export const registerSchema = z.object({ + body: z.object({ + name: z.string().min(1).max(100), + email: z.string().email().max(300), + password: z + .string() + .min(8) + .max(72) + .regex(/[A-Z]/, "Must contain at least one uppercase letter") + .regex(/[0-9]/, "Must contain at least one number") + .regex(/[^a-zA-Z0-9]/, "Must contain at least one special character"), + role: z.enum(["FARMER", "INSPECTOR", "MANAGER", "ADMIN", "DEVELOPER"]), + }), +}); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 25bee56..e99a77c 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,9 +1,12 @@ +import { hashPassword } from "../../lib/bcrypt"; import { AppError } from "../../middleware/errorHandler"; import { ERROR_CODES } from "../../utils/errorCodes"; import type { ForgotPasswordRequestBody, JwtPayload, LoginRequestBody, + RegisterRequestBody, + RegisterResponse, ResetPasswordRequestBody, } from "./auth.types"; import { AuthRepository } from "./auth.repository"; @@ -36,6 +39,42 @@ export class AuthService { throw new AppError(501, ERROR_CODES.AUTH_006, "AUTH_006"); } + async register(payload: RegisterRequestBody): Promise { + // 1. Check duplicate email + const existing = await this.authRepository.findUserByEmail(payload.email); + if (existing) { + throw new AppError(409, ERROR_CODES.DATA_002, "DATA_002"); + } + + // 2. Find role ID from role name + const role = await this.authRepository.findRoleByName(payload.role); + if (!role) { + throw new AppError(400, ERROR_CODES.VAL_001, "VAL_001"); + } + + // 3. Hash password + const passwordHash = await hashPassword(payload.password); + + // 4. Create user + const user = await this.authRepository.createUser({ + name: payload.name, + email: payload.email, + passwordHash, + roleId: role.id, + }); + + // 5. Return safe response + return { + success: true, + user: { + id: user.id, + name: user.name, + email: user.email ?? "", + role: payload.role, + }, + }; + } + private async ensureAuthReadiness(): Promise { await Promise.resolve(this.authRepository.getRoleModelAvailability()); } diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index 3a719f1..4ba896d 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -28,6 +28,23 @@ export interface ResetPasswordRequestBody { password: string; } +export interface RegisterRequestBody { + name: string; + email: string; + password: string; + role: RoleName; +} + +export interface RegisterResponse { + success: boolean; + user: { + id: number; + name: string; + email: string; + role: RoleName; + }; +} + export interface AuthRouteResponse { success: boolean; message: string; diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts new file mode 100644 index 0000000..11d3b54 --- /dev/null +++ b/tests/integration/auth.test.ts @@ -0,0 +1,146 @@ +import request from "supertest"; +import express from "express"; +import { errorHandler } from "../../src/middleware/errorHandler"; +import { securityAuditMiddleware } from "../../src/middleware/securityAudit.middleware"; +import { AppError } from "../../src/middleware/errorHandler"; +import { ERROR_CODES } from "../../src/utils/errorCodes"; + +const mockRegister = jest.fn(); +const mockLogin = jest.fn(); +const mockLogout = jest.fn(); +const mockForgotPassword = jest.fn(); +const mockResetPassword = jest.fn(); +const mockMe = jest.fn(); +const mockGetProtectedTest = jest.fn(); +const mockGetAdminTest = jest.fn(); +const mockGetProjectScopeTest = jest.fn(); + +jest.mock("../../src/modules/auth/auth.controller", () => ({ + AuthController: jest.fn().mockImplementation(() => ({ + register: mockRegister, + login: mockLogin, + logout: mockLogout, + forgotPassword: mockForgotPassword, + resetPassword: mockResetPassword, + me: mockMe, + getProtectedTest: mockGetProtectedTest, + getAdminTest: mockGetAdminTest, + getProjectScopeTest: mockGetProjectScopeTest, + })), +})); + +import authRoutes from "../../src/modules/auth/auth.routes"; + +const app = express(); +app.use(express.json()); +app.use(securityAuditMiddleware); +app.use("/auth", authRoutes); +app.use(errorHandler); + +describe("POST /auth/register", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return 201 for valid registration", async () => { + mockRegister.mockImplementation((_req: any, res: any) => { + res.status(201).json({ + success: true, + user: { + id: 1, + name: "Test User", + email: "test@treeo2.com", + role: "FARMER", + }, + }); + }); + + const response = await request(app).post("/auth/register").send({ + name: "Test User", + email: "test@treeo2.com", + password: "Test@1234", + role: "FARMER", + }); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + expect(response.body.user.email).toBe("test@treeo2.com"); + }); + + it("should return 400 for missing fields", async () => { + const response = await request(app).post("/auth/register").send({ + email: "test@treeo2.com", + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("should return 400 for weak password", async () => { + const response = await request(app).post("/auth/register").send({ + name: "Test User", + email: "test@treeo2.com", + password: "weak", + role: "FARMER", + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("should return 400 for invalid role", async () => { + const response = await request(app).post("/auth/register").send({ + name: "Test User", + email: "test@treeo2.com", + password: "Test@1234", + role: "SUPERADMIN", + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it("should return 409 for duplicate email", async () => { + mockRegister.mockImplementation(async (_req: any, res: any) => { + res.status(409).json({ + success: false, + message: "DATA_002: Duplicate entry", + code: "DATA_002", + }); + }); + + const response = await request(app).post("/auth/register").send({ + name: "Test User", + email: "test@treeo2.com", + password: "Test@1234", + role: "FARMER", + }); + + expect(response.status).toBe(409); + expect(response.body.success).toBe(false); + }); + + it("should not expose password hash in response", async () => { + mockRegister.mockImplementation((_req: any, res: any) => { + res.status(201).json({ + success: true, + user: { + id: 1, + name: "Test User", + email: "test@treeo2.com", + role: "FARMER", + }, + }); + }); + + const response = await request(app).post("/auth/register").send({ + name: "Test User", + email: "test@treeo2.com", + password: "Test@1234", + role: "FARMER", + }); + + expect(response.body.user).not.toHaveProperty("passwordHash"); + expect(response.body.user).not.toHaveProperty("password"); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..613f769 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,10 @@ +process.env.NODE_ENV = "development"; +process.env.JWT_SECRET = "test-secret-key-minimum-32-characters-long"; +process.env.JWT_EXPIRES_IN = "24h"; +process.env.AUTH_DEV_MODE = "false"; +process.env.RATE_LIMIT_WINDOW_MS = "900000"; +process.env.RATE_LIMIT_MAX = "100"; +process.env.LOG_TO_FILE = "false"; +process.env.PORT = "3000"; +process.env.DATABASE_URL = + "postgresql://treeo2_user:treeo2_password@localhost:5432/treeo2?schema=public"; diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts new file mode 100644 index 0000000..ecd9e68 --- /dev/null +++ b/tests/unit/auth.test.ts @@ -0,0 +1,95 @@ +import { AuthService } from "../../src/modules/auth/auth.service"; +import { AuthRepository } from "../../src/modules/auth/auth.repository"; + +jest.mock("../../src/modules/auth/auth.repository"); + +const mockRepository = { + findUserByEmail: jest.fn(), + findRoleByName: jest.fn(), + createUser: jest.fn(), + getRoleModelAvailability: jest.fn().mockReturnValue(true), +}; + +(AuthRepository as jest.Mock).mockImplementation(() => mockRepository); + +describe("AuthService - register", () => { + let service: AuthService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new AuthService(); + }); + + it("should successfully register a new user", async () => { + mockRepository.findUserByEmail.mockResolvedValue(null); + mockRepository.findRoleByName.mockResolvedValue({ id: 1, name: "FARMER" }); + mockRepository.createUser.mockResolvedValue({ + id: 1, + name: "Test User", + email: "test@treeo2.com", + roleId: 1, + }); + + const result = await service.register({ + name: "Test User", + email: "test@treeo2.com", + password: "Test@1234", + role: "FARMER", + }); + + expect(result.success).toBe(true); + expect(result.user.email).toBe("test@treeo2.com"); + expect(result.user.role).toBe("FARMER"); + }); + + it("should throw 409 if email already exists", async () => { + mockRepository.findUserByEmail.mockResolvedValue({ + id: 1, + email: "test@treeo2.com", + }); + + await expect( + service.register({ + name: "Test User", + email: "test@treeo2.com", + password: "Test@1234", + role: "FARMER", + }), + ).rejects.toMatchObject({ statusCode: 409 }); + }); + + it("should throw 400 if role not found", async () => { + mockRepository.findUserByEmail.mockResolvedValue(null); + mockRepository.findRoleByName.mockResolvedValue(null); + + await expect( + service.register({ + name: "Test User", + email: "test@treeo2.com", + password: "Test@1234", + role: "FARMER", + }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it("should not return password hash in response", async () => { + mockRepository.findUserByEmail.mockResolvedValue(null); + mockRepository.findRoleByName.mockResolvedValue({ id: 1, name: "FARMER" }); + mockRepository.createUser.mockResolvedValue({ + id: 1, + name: "Test User", + email: "test@treeo2.com", + roleId: 1, + }); + + const result = await service.register({ + name: "Test User", + email: "test@treeo2.com", + password: "Test@1234", + role: "FARMER", + }); + + expect(result.user).not.toHaveProperty("passwordHash"); + expect(result.user).not.toHaveProperty("password"); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..1958247 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["jest", "node"] + }, + "include": ["src/**/*", "tests/**/*"] +}