From b3b7459593dd014264143b925e22d8bf6b2593f0 Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 20:56:31 +1000 Subject: [PATCH 1/8] fix: enable prismaSchemaFolder so model files in prisma/models/ are picked up --- prisma/schema.prisma | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d37099d..360f1b4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["prismaSchemaFolder"] } datasource db { From 241e197aa0d149cc5dafc3be59424b79b7c2adf2 Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 20:56:55 +1000 Subject: [PATCH 2/8] fix: strip projectIds from updateUser before Prisma call; add password validation and hashing to createUser --- .../user-management/userManagement.service.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/modules/user-management/userManagement.service.ts b/src/modules/user-management/userManagement.service.ts index 97a785c..f060bdd 100644 --- a/src/modules/user-management/userManagement.service.ts +++ b/src/modules/user-management/userManagement.service.ts @@ -2,6 +2,7 @@ import { prisma } from "../../lib/prisma"; import { ERROR_CODES } from "../../utils/errorCodes"; import { AppError } from "../../middleware/errorHandler"; import type { Prisma } from "@prisma/client"; +import { hashPassword } from "../../lib/bcrypt"; export type AuthUser = { id: number; @@ -12,6 +13,7 @@ export type AuthUser = { export type CreateUserInput = { name: string; email: string; + password?: string; roleId: number; projectIds?: number[]; }; @@ -135,6 +137,26 @@ export const UserManagementService = { throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); } + if (data.email.length > 300) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + + // Validate password complexity if provided + if (data.password) { + if (data.password.length < 8 || data.password.length > 72) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + if (!/[A-Z]/.test(data.password)) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + if (!/[0-9]/.test(data.password)) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + if (!/[^a-zA-Z0-9]/.test(data.password)) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + } + const role = await prisma.role.findUnique({ where: { id: data.roleId }, }); @@ -151,11 +173,17 @@ export const UserManagementService = { throw new AppError(409, ERROR_CODES.DATA_002, ERROR_CODES.DATA_002); } + // Hash password if provided + const passwordHash = data.password + ? await hashPassword(data.password) + : undefined; + return prisma.user.create({ data: { name: data.name.trim(), email: data.email.trim(), roleId: data.roleId, + ...(passwordHash && { passwordHash }), }, select: userSelect, }); @@ -199,9 +227,11 @@ export const UserManagementService = { } } + const { projectIds: _projectIds, ...dbData } = data; + return prisma.user.update({ where: { id: userId }, - data, + data: dbData, select: userSelect, }); }, From a5625e322e5fb5a7e5f3680a5607cdbbccc306bd Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 20:59:20 +1000 Subject: [PATCH 3/8] test: add deleteUser tests and RBAC coverage for getUsers --- tests/unit/user-management.test.ts | 64 +++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/tests/unit/user-management.test.ts b/tests/unit/user-management.test.ts index e187e1e..05b1d59 100644 --- a/tests/unit/user-management.test.ts +++ b/tests/unit/user-management.test.ts @@ -95,7 +95,6 @@ describe("UserManagementService - UNIT TESTS (FIXED)", () => { email: "test@test.com", roleId: 1, }); - expect(result.id).toBe(1); }); @@ -183,5 +182,68 @@ describe("UserManagementService - UNIT TESTS (FIXED)", () => { code: expect.stringContaining("AUTH_004"), }); }); + + it("should block INSPECTOR from getUsers", async () => { + await expect( + UserManagementService.getUsers({ id: 1, role: "INSPECTOR" }), + ).rejects.toMatchObject({ + statusCode: 403, + code: expect.stringContaining("AUTH_004"), + }); + }); + + it("should block FARMER from getUsers", async () => { + await expect( + UserManagementService.getUsers({ id: 1, role: "FARMER" }), + ).rejects.toMatchObject({ + statusCode: 403, + code: expect.stringContaining("AUTH_004"), + }); + }); + }); + + // DELETE USER + describe("deleteUser", () => { + it("should soft-delete user", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 1 }); + mockPrisma.treeScan.findFirst.mockResolvedValue(null); + mockPrisma.user.update.mockResolvedValue({ id: 1 }); + + const result = await UserManagementService.deleteUser("1"); + + expect(result).toBe(true); + }); + + it("should throw not found", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + UserManagementService.deleteUser("999"), + ).rejects.toMatchObject({ + statusCode: 404, + code: expect.stringContaining("DATA_001"), + }); + }); + + it("should throw invalid id", async () => { + await expect( + UserManagementService.deleteUser("abc"), + ).rejects.toMatchObject({ + statusCode: 400, + code: expect.stringContaining("VAL_002"), + }); + }); + + it("should block delete when user has linked tree scans", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 1 }); + mockPrisma.treeScan.findFirst.mockResolvedValue({ id: 99 }); + + await expect( + UserManagementService.deleteUser("1"), + ).rejects.toMatchObject({ + statusCode: 409, + code: expect.stringContaining("VAL_001"), + }); + }); }); }); \ No newline at end of file From d4ead9c24cbff33719c6a032a81215f6c66a6c82 Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 21:18:11 +1000 Subject: [PATCH 4/8] config: add tsconfig.test.json and update jest to resolve jest globals in test files --- jest.config.js | 4 +++- tsconfig.test.json | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tsconfig.test.json diff --git a/jest.config.js b/jest.config.js index 3d14f5e..ec860b3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,9 +1,11 @@ /** @type {import('jest').Config} */ module.exports = { - preset: 'ts-jest', testEnvironment: 'node', roots: ['/tests'], testMatch: ['**/*.test.ts'], collectCoverageFrom: ['src/**/*.ts', '!src/index.ts'], coverageDirectory: 'coverage', + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }], + }, }; \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..9733f48 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist-test", + "types": ["jest", "node"] + }, + "include": ["src/**/*", "tests/**/*"] +} \ No newline at end of file From de1473d15180e2df978d607d76c0b1ff0a76b289 Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 21:18:24 +1000 Subject: [PATCH 5/8] test: rewrite user management integration tests with full endpoint and RBAC coverage --- tests/integration/user-management.test.ts | 360 ++++++++++++++++++---- 1 file changed, 296 insertions(+), 64 deletions(-) diff --git a/tests/integration/user-management.test.ts b/tests/integration/user-management.test.ts index c9b8948..fd06eeb 100644 --- a/tests/integration/user-management.test.ts +++ b/tests/integration/user-management.test.ts @@ -1,102 +1,334 @@ +import "dotenv/config"; import request from "supertest"; -import express from "express"; -import userRoutes from "../../src/modules/user-management/userManagement.routes"; -import { prisma } from "../../src/lib/prisma"; +import { PrismaClient } from "@prisma/client"; import jwt from "jsonwebtoken"; +import app from "../../src/app"; -const app = express(); -app.use(express.json()); -app.use("/users", userRoutes); +const prisma = new PrismaClient(); -const generateToken = (user: any) => - jwt.sign(user, process.env.JWT_SECRET as string); +const sign = (payload: object) => + jwt.sign(payload, process.env.JWT_SECRET as string); + +const TOKENS = { + ADMIN: sign({ id: 1, role: "ADMIN" }), + MANAGER: sign({ id: 2, role: "MANAGER", projectIds: [] }), + INSPECTOR: sign({ id: 3, role: "INSPECTOR" }), + FARMER: sign({ id: 4, role: "FARMER" }), +}; describe("User Management Integration Tests", () => { - let adminToken: string; + let roleId: number; + let userId: number; beforeEach(async () => { await prisma.treeScan.deleteMany(); await prisma.user.deleteMany(); await prisma.role.deleteMany(); - const role = await prisma.role.create({ - data: { id: 1, name: "ADMIN" }, - }); - - await prisma.user.create({ - data: { - id: 1, - name: "Admin", - email: "admin@test.com", - roleId: role.id, - }, - }); + const role = await prisma.role.create({ data: { name: "ADMIN" } }); + roleId = role.id; - adminToken = generateToken({ - id: 1, - role: "ADMIN", - projectIds: [], + const user = await prisma.user.create({ + data: { name: "Test Admin", email: "admin@test.com", roleId }, }); + userId = user.id; }); afterAll(async () => { + await prisma.treeScan.deleteMany(); + await prisma.user.deleteMany(); + await prisma.role.deleteMany(); await prisma.$disconnect(); }); - it("GET /users → 200", async () => { - const res = await request(app) - .get("/users") - .set("Authorization", `Bearer ${adminToken}`); + // ── GET /users ────────────────────────────────────────────────────────────── + describe("GET /users", () => { + it("should return 401 with no token", async () => { + const res = await request(app).get("/users"); + expect(res.status).toBe(401); + }); + + it("should return 200 for ADMIN", async () => { + const res = await request(app) + .get("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it("should return 200 for MANAGER", async () => { + const res = await request(app) + .get("/users") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(res.status).toBe(200); + }); + + it("should return 403 for INSPECTOR", async () => { + const res = await request(app) + .get("/users") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(res.status).toBe(403); + }); + + it("should return 403 for FARMER", async () => { + const res = await request(app) + .get("/users") + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(res.status).toBe(403); + }); + + it("should return 400 for non-numeric project query param", async () => { + const res = await request(app) + .get("/users?project=abc") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(400); + }); + + it("should return 200 when filtering by valid project id", async () => { + const res = await request(app) + .get("/users?project=1") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); - expect(res.status).toBe(200); + expect(res.status).toBe(200); + }); }); - it("POST /users → 201", async () => { - const res = await request(app) - .post("/users") - .set("Authorization", `Bearer ${adminToken}`) - .send({ - name: "New User", - email: "new@test.com", - roleId: 1, + // ── GET /users/:id ────────────────────────────────────────────────────────── + describe("GET /users/:id", () => { + it("should return 401 with no token", async () => { + const res = await request(app).get(`/users/${userId}`); + expect(res.status).toBe(401); + }); + + it("should return 200 and the user for ADMIN", async () => { + const res = await request(app) + .get(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(userId); + expect(res.body.email).toBe("admin@test.com"); + }); + + it("should return 404 when user does not exist", async () => { + const res = await request(app) + .get("/users/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(404); + }); + + it("should return 400 for non-numeric id", async () => { + const res = await request(app) + .get("/users/abc") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(400); + }); + + it("should return 403 when INSPECTOR requests another user's profile", async () => { + const res = await request(app) + .get(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(res.status).toBe(403); + }); + + it("should return 200 when INSPECTOR requests their own profile", async () => { + const inspector = await prisma.user.create({ + data: { name: "Inspector", email: "inspector@test.com", roleId }, }); - expect(res.status).toBe(201); + const token = sign({ id: inspector.id, role: "INSPECTOR" }); + + const res = await request(app) + .get(`/users/${inspector.id}`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(inspector.id); + }); }); - it("PUT /users/:id → 200", async () => { - const res = await request(app) - .put("/users/1") - .set("Authorization", `Bearer ${adminToken}`) - .send({ name: "Updated" }); + // ── POST /users ───────────────────────────────────────────────────────────── + describe("POST /users", () => { + it("should return 401 with no token", async () => { + const res = await request(app) + .post("/users") + .send({ name: "New User", email: "new@test.com", roleId }); + + expect(res.status).toBe(401); + }); + + it("should return 403 for MANAGER", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`) + .send({ name: "New User", email: "new@test.com", roleId }); + + expect(res.status).toBe(403); + }); + + it("should return 403 for INSPECTOR", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ name: "New User", email: "new@test.com", roleId }); + + expect(res.status).toBe(403); + }); + + it("should return 201 and the created user for ADMIN", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "New User", email: "new@test.com", roleId }); + + expect(res.status).toBe(201); + expect(res.body.email).toBe("new@test.com"); + expect(res.body.name).toBe("New User"); + }); + + it("should return 400 for invalid email format", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "New User", email: "not-an-email", roleId }); + + expect(res.status).toBe(400); + }); + + it("should return 400 for missing name", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "", email: "new@test.com", roleId }); - expect(res.status).toBe(200); + expect(res.status).toBe(400); + }); + + it("should return 409 for duplicate email", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "Duplicate", email: "admin@test.com", roleId }); + + expect(res.status).toBe(409); + }); + + it("should return 400 when roleId does not exist", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "New User", email: "new@test.com", roleId: 999999 }); + + expect(res.status).toBe(400); + }); }); - it("DELETE /users/:id → 200", async () => { - const res = await request(app) - .delete("/users/1") - .set("Authorization", `Bearer ${adminToken}`); + // ── PUT /users/:id ────────────────────────────────────────────────────────── + describe("PUT /users/:id", () => { + it("should return 401 with no token", async () => { + const res = await request(app) + .put(`/users/${userId}`) + .send({ name: "Updated" }); + + expect(res.status).toBe(401); + }); + + it("should return 200 and the updated user for ADMIN", async () => { + const res = await request(app) + .put(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "Updated Name" }); + + expect(res.status).toBe(200); + expect(res.body.name).toBe("Updated Name"); + }); + + it("should return 403 for INSPECTOR", async () => { + const res = await request(app) + .put(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ name: "Updated" }); + + expect(res.status).toBe(403); + }); + + it("should return 404 when user does not exist", async () => { + const res = await request(app) + .put("/users/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "Updated" }); + + expect(res.status).toBe(404); + }); + + it("should return 400 for non-numeric id", async () => { + const res = await request(app) + .put("/users/abc") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "Updated" }); - expect(res.status).toBe(200); + expect(res.status).toBe(400); + }); }); - it("RBAC → inspector blocked", async () => { - const token = generateToken({ - id: 2, - role: "INSPECTOR", - projectIds: [], + // ── DELETE /users/:id ─────────────────────────────────────────────────────── + describe("DELETE /users/:id", () => { + it("should return 401 with no token", async () => { + const res = await request(app).delete(`/users/${userId}`); + expect(res.status).toBe(401); }); - const res = await request(app) - .post("/users") - .set("Authorization", `Bearer ${token}`) - .send({ - name: "Blocked", - email: "blocked@test.com", - roleId: 1, - }); + it("should return 200 and soft-delete the user for ADMIN", async () => { + const res = await request(app) + .delete(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.message).toBe("User deactivated successfully"); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + expect(user?.accountActive).toBe(false); + expect(user?.canSignIn).toBe(false); + }); - expect(res.status).toBe(403); + it("should return 403 for MANAGER", async () => { + const res = await request(app) + .delete(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(res.status).toBe(403); + }); + + it("should return 403 for INSPECTOR", async () => { + const res = await request(app) + .delete(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(res.status).toBe(403); + }); + + it("should return 404 when user does not exist", async () => { + const res = await request(app) + .delete("/users/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(404); + }); + + it("should return 400 for non-numeric id", async () => { + const res = await request(app) + .delete("/users/abc") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(400); + }); }); }); \ No newline at end of file From 42b91271f731cf3db0dc7dcfadb6e7746e8a1114 Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 21:32:24 +1000 Subject: [PATCH 6/8] chore: update package-lock.json --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index e1688a5..fd30482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8660,4 +8660,4 @@ } } } -} \ No newline at end of file +} From d1ac8e23857827d0acae73cb1598b825f3d39742 Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 21:54:02 +1000 Subject: [PATCH 7/8] fix: resolve lint error for unused variable in updateUser --- src/modules/user-management/userManagement.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/user-management/userManagement.service.ts b/src/modules/user-management/userManagement.service.ts index f060bdd..55deeac 100644 --- a/src/modules/user-management/userManagement.service.ts +++ b/src/modules/user-management/userManagement.service.ts @@ -227,7 +227,7 @@ export const UserManagementService = { } } - const { projectIds: _projectIds, ...dbData } = data; + const { projectIds: _, ...dbData } = data; return prisma.user.update({ where: { id: userId }, From 7d99dfa51569d24769ac41df922bc8454e5e5c5f Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 21:59:22 +1000 Subject: [PATCH 8/8] fix: resolve lint error for unused variable in updateUser --- src/modules/user-management/userManagement.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/user-management/userManagement.service.ts b/src/modules/user-management/userManagement.service.ts index 55deeac..33aa4b2 100644 --- a/src/modules/user-management/userManagement.service.ts +++ b/src/modules/user-management/userManagement.service.ts @@ -227,7 +227,8 @@ export const UserManagementService = { } } - const { projectIds: _, ...dbData } = data; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { projectIds, ...dbData } = data; return prisma.user.update({ where: { id: userId },