diff --git a/backend/jest.config.js b/backend/jest.config.js index 3586e72..d872a6c 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,13 +1,19 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/src'], - transform: { - '^.+\\.tsx?$': ['ts-jest', { - tsconfig: 'tsconfig.json', - }], - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', - collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'], + setupFilesAfterEnv: ['/src/tests/setup/testSetup.ts'], + testMatch: [ + '/src/**/__tests__/**/*.test.ts', + '/src/tests/**/*.test.ts' + ], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/tests/**', + '!src/**/__tests__/**' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + modulePathIgnorePatterns: ['/dist/'], + testTimeout: 10000, }; \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index df6d20e..53e017e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -44,6 +44,7 @@ "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.12.12", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "eslint": "^8.56.0", @@ -54,6 +55,7 @@ "jest-mock-extended": "^4.0.0-beta1", "prettier": "^3.2.5", "prisma": "^6.8.2", + "supertest": "^7.1.1", "ts-jest": "^29.1.4", "ts-node": "^10.9.1", "ts-node-dev": "^2.0.0", diff --git a/backend/src/authorization/middleware/rbacMiddleware.ts b/backend/src/authorization/middleware/rbacMiddleware.ts index f21a663..00c9a97 100644 --- a/backend/src/authorization/middleware/rbacMiddleware.ts +++ b/backend/src/authorization/middleware/rbacMiddleware.ts @@ -138,3 +138,38 @@ export const requireAny = (permissionsOrRoles: (Permission | UserRole)[]) => { next(); }; }; + +/** + * Middleware to check if the authenticated user belongs to the same organization as a target entity. + * The target entity's organization ID is expected in req.params. + * @param paramKeyForTargetOrgId The key in req.params that holds the target organization ID. Defaults to 'organizationId'. + * @returns Express middleware + */ +export const requireSameOrganization = (paramKeyForTargetOrgId: string = 'organizationId') => { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.user) { + return next(new HttpException(401, 'Authentication required')); + } + + if (!req.user.organizationId) { + return next(new HttpException(403, 'User does not belong to any organization')); + } + + const targetOrganizationId = req.params[paramKeyForTargetOrgId]; + + if (!targetOrganizationId) { + return next( + new HttpException( + 400, + `Target organization ID not found in request parameters using key: '${paramKeyForTargetOrgId}'`, + ), + ); + } + + if (req.user.organizationId !== targetOrganizationId) { + return next(new HttpException(403, 'User does not belong to the target organization')); + } + + next(); + }; +}; diff --git a/backend/src/authorization/middleware/resourceAccessMiddleware.ts b/backend/src/authorization/middleware/resourceAccessMiddleware.ts index 5f398fb..2b895fa 100644 --- a/backend/src/authorization/middleware/resourceAccessMiddleware.ts +++ b/backend/src/authorization/middleware/resourceAccessMiddleware.ts @@ -93,13 +93,77 @@ export const requireResourceAccess = ( }; }; +// Helper function to get organization ID for a resource +const getResourceOrganizationId = async ( + resourceType: ResourceType, + resourceId: string, +): Promise => { + switch (resourceType) { + case ResourceType.USER: { + const resourceUser = await prisma.user.findUnique({ where: { id: resourceId } }); + return resourceUser?.organizationId; + } + case ResourceType.ADMIN: { + const admin = await prisma.admin.findUnique({ where: { id: resourceId } }); + // Assuming Admin model might have an optional organizationId. + // Adjust if Admin model definitely doesn't have it or has a different relation. + return admin && 'organizationId' in admin + ? (admin as Admin & { organizationId?: string | null }).organizationId + : null; // Or undefined, depending on how you want to treat admins without orgs + } + case ResourceType.LAB: { + const lab = await prisma.lab.findUnique({ where: { id: resourceId } }); + return lab?.organizationId; + } + case ResourceType.TIME_SLOT: { + const timeSlot = await prisma.timeSlot.findUnique({ + where: { id: resourceId }, + include: { lab: true }, + }); + return timeSlot?.lab?.organizationId; + } + case ResourceType.BOOKING: { + const booking = await prisma.booking.findUnique({ + where: { id: resourceId }, + include: { timeSlot: { include: { lab: true } } }, + }); + return booking?.timeSlot?.lab?.organizationId; + } + case ResourceType.WAITLIST: { + const waitlist = await prisma.waitlist.findUnique({ + where: { id: resourceId }, + include: { timeSlot: { include: { lab: true } } }, + }); + return waitlist?.timeSlot?.lab?.organizationId; + } + case ResourceType.NOTIFICATION: { + const notification = await prisma.notification.findUnique({ + where: { id: resourceId }, + include: { user: true }, + }); + return notification?.user?.organizationId; + } + case ResourceType.ORGANIZATION: { + return resourceId; // The resourceId itself is the organizationId + } + default: { + // This should be caught by TypeScript if all ResourceType cases are handled. + // If new types are added without updating this, it will throw at runtime. + const exhaustiveCheck: never = resourceType; + throw new HttpException( + 500, + `Organization check not implemented for resource type: ${exhaustiveCheck}`, + ); + } + } +}; + export const requireSameOrganization = ( resourceType: ResourceType, options: ResourceAccessOptions = {}, ) => { return async (req: Request, res: Response, next: NextFunction): Promise => { - // Added return type Promise - const user = req.user as RequestUser | undefined; // Cast is okay here after check + const user = req.user as RequestUser | undefined; if (!user) { return next(new HttpException(401, 'Authentication required')); @@ -126,84 +190,10 @@ export const requireSameOrganization = ( } try { - let resourceOrganizationId: string | null | undefined = null; - - switch (resourceType) { - case ResourceType.USER: { - const resourceUser = await prisma.user.findUnique({ where: { id: resourceId } }); - resourceOrganizationId = resourceUser?.organizationId; - break; - } - case ResourceType.ADMIN: { - const admin: Admin | null = await prisma.admin.findUnique({ where: { id: resourceId } }); - // Assuming Admin model might have an optional organizationId or a relation to it. - // If Admin model *definitely* doesn't have it, this case needs rethinking. - // For now, let's assume it *could* have it, making it type-safe. - // You'll need to define organizationId on the Admin model in schema.prisma for this to be truly effective. - if (admin && 'organizationId' in admin) { - resourceOrganizationId = (admin as Admin & { organizationId?: string | null }) - .organizationId; - } else { - // Handle case where admin is found but has no organizationId property, - // or if Admins are not organization-specific. - // This might mean access is allowed, or denied, or this check is not applicable. - // For now, if no organizationId, it won't match userOrganizationId unless both are null/undefined. - } - break; - } - case ResourceType.LAB: { - const lab = await prisma.lab.findUnique({ where: { id: resourceId } }); - resourceOrganizationId = lab?.organizationId; - break; - } - case ResourceType.TIME_SLOT: { - const timeSlot = await prisma.timeSlot.findUnique({ - where: { id: resourceId }, - include: { lab: true }, - }); - resourceOrganizationId = timeSlot?.lab?.organizationId; - break; - } - case ResourceType.BOOKING: { - const booking = await prisma.booking.findUnique({ - where: { id: resourceId }, - include: { timeSlot: { include: { lab: true } } }, - }); - resourceOrganizationId = booking?.timeSlot?.lab?.organizationId; - break; - } - case ResourceType.WAITLIST: { - const waitlist = await prisma.waitlist.findUnique({ - where: { id: resourceId }, - include: { timeSlot: { include: { lab: true } } }, - }); - resourceOrganizationId = waitlist?.timeSlot?.lab?.organizationId; - break; - } - case ResourceType.NOTIFICATION: { - const notification = await prisma.notification.findUnique({ - where: { id: resourceId }, - include: { user: true }, - }); - resourceOrganizationId = notification?.user?.organizationId; - break; - } - case ResourceType.ORGANIZATION: { - resourceOrganizationId = resourceId; - break; - } - default: { - const exhaustiveCheck: never = resourceType; - return next( - new HttpException( - 500, - `Organization check not implemented for resource type: ${exhaustiveCheck}`, - ), - ); - } - } + const resourceOrganizationId = await getResourceOrganizationId(resourceType, resourceId); - if (!resourceOrganizationId) { + if (resourceOrganizationId === undefined || resourceOrganizationId === null) { + // Check for both undefined and null return next( new HttpException(404, 'Resource not found or not associated with an organization'), ); @@ -215,7 +205,7 @@ export const requireSameOrganization = ( next(); } catch (error) { - console.error('Organization access check failed:', error); + console.error('Organization access check failed:', error); // Keep console.error for critical middleware errors if (error instanceof HttpException) { next(error); } else { diff --git a/backend/src/authorization/utils/permissionChecker.ts b/backend/src/authorization/utils/permissionChecker.ts index 8377379..6e5ddcb 100644 --- a/backend/src/authorization/utils/permissionChecker.ts +++ b/backend/src/authorization/utils/permissionChecker.ts @@ -45,6 +45,15 @@ export const hasAllPermissions = (user: AuthUser, permissions: Permission[]): bo return permissions.every(permission => hasPermission(user, permission)); }; +/** + * Get all permissions for a given user's role + * @param user The authenticated user + * @returns Array of permissions + */ +export const getUserPermissions = (user: AuthUser): Permission[] => { + return getPermissionsForRole(user.user_role); +}; + /** * Check if a user can access a specific resource based on ownership and permissions * @param user The authenticated user diff --git a/backend/src/config/environment.ts b/backend/src/config/environment.ts index a5e14fb..156c9b7 100644 --- a/backend/src/config/environment.ts +++ b/backend/src/config/environment.ts @@ -19,7 +19,7 @@ const envSchema = z.object({ : z.string(), JWT_SECRET: process.env.NODE_ENV === 'test' - ? z.string().optional().default('test_jwt_secret_schema_default') + ? z.string().optional().default('test-secret') // Changed to match authTestUtils.ts : z.string(), JWT_EXPIRES_IN: z.string().default('15m'), REFRESH_TOKEN_SECRET: @@ -54,10 +54,10 @@ export const config = env.success DATABASE_URL: process.env.DATABASE_URL ?? 'postgresql://postgres:password@localhost:5432/test_db_fallback', - JWT_SECRET: process.env.JWT_SECRET ?? 'test_jwt_secret_fallback', + JWT_SECRET: process.env.JWT_SECRET ?? 'test-secret', // Changed fallback to match JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN ?? '15m', REFRESH_TOKEN_SECRET: process.env.REFRESH_TOKEN_SECRET ?? 'test_refresh_secret_fallback', REFRESH_TOKEN_EXPIRES_IN: process.env.REFRESH_TOKEN_EXPIRES_IN ?? '7d', - REDIS_URL: process.env.REDIS_URL ?? undefined, // Ensure REDIS_URL is in the fallback + REDIS_URL: process.env.REDIS_URL ?? undefined, CORS_ORIGIN: process.env.CORS_ORIGIN ?? '*', }; diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 0287d85..60e5bb9 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -4,14 +4,15 @@ import { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import authService from '../services/auth.service'; import { sendSuccess, sendError } from '../utils/response'; -import { errorTypes } from 'src/utils/errors'; +import { errorTypes } from '../utils/errors'; // Changed from 'src/utils/errors' // Validation schemas const registerSchema = z.object({ email: z.string().email(), - password: z.string(), - firstName: z.string(), - lastName: z.string(), + password: z.string().min(8), // Added min length for password + firstName: z.string().min(1), + lastName: z.string().min(1), + organizationId: z.string().uuid().optional(), // Added organizationId, assuming UUID }); const loginSchema = z.object({ @@ -37,20 +38,23 @@ export class AuthController { user_email: validatedData.email, user_password: validatedData.password, user_name: `${validatedData.firstName} ${validatedData.lastName}`, + organizationId: validatedData.organizationId, }; + const user = await authService.register(userData); - sendSuccess(res, user, 201); + sendSuccess(res, { user }, 201); } catch (error) { if (error instanceof z.ZodError) { + // Handle ZodError specifically return sendError( res, 'Validation error', errorTypes.BAD_REQUEST, 'VALIDATION_ERROR', - error.errors, + error.errors, // Pass Zod issues for detailed error response ); } - next(error); + next(error); // Pass other errors to the global error handler } } @@ -58,13 +62,17 @@ export class AuthController { async login(req: Request, res: Response, next: NextFunction): Promise { try { const { email, password } = loginSchema.parse(req.body); - const { accessToken, user } = await authService.login(email, password); + const { accessToken, user, refreshToken } = await authService.login(email, password); - // Set refresh token as HttpOnly cookie - //const refreshToken = req.cookies?.refreshToken; - - // Send access token in response body - sendSuccess(res, { accessToken, user }); + if (refreshToken) { + res.cookie('refreshToken', refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + path: '/api/auth/refresh-token', // Ensure this path is correct + // maxAge: parseDurationToSeconds(config.REFRESH_TOKEN_EXPIRES_IN) * 1000, // Use your helper + }); + } + sendSuccess(res, { user, accessToken }); // Send user and accessToken } catch (error) { if (error instanceof z.ZodError) { return sendError( diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts index 5856cb4..b35825c 100644 --- a/backend/src/middleware/auth.middleware.ts +++ b/backend/src/middleware/auth.middleware.ts @@ -6,6 +6,7 @@ import { verifyAccessToken, extractTokenFromHeader } from '../utils/jwt'; import { AppError, errorTypes } from '../utils/errors'; import { sendError } from '../utils/response'; import rateLimit from 'express-rate-limit'; +import jwt from 'jsonwebtoken'; // Ensure jwt is imported for specific error types const prisma = new PrismaClient(); @@ -15,6 +16,8 @@ declare module 'express' { user?: { id: string; role: UserRole; + email?: string; // Added email + organizationId?: string | null; // Added organizationId }; } } @@ -29,34 +32,54 @@ export const authenticate = async ( const authHeader = req.headers.authorization; if (!authHeader) { - return sendError(res, 'Authentication required', errorTypes.UNAUTHORIZED); + // Corrected: Pass the errorCode 'AUTH_REQUIRED' + return sendError(res, 'Authentication required', errorTypes.UNAUTHORIZED, 'AUTH_REQUIRED'); } - const token = extractTokenFromHeader(authHeader); - const decoded = verifyAccessToken(token); + const token = extractTokenFromHeader(authHeader); // This might throw if header is malformed + const decoded = verifyAccessToken(token); // This will throw if token is invalid/expired // Check if user exists const user = await prisma.user.findUnique({ where: { id: decoded.userId }, - select: { id: true, user_role: true }, + select: { id: true, user_role: true, user_email: true, organizationId: true }, }); if (!user) { - return sendError(res, 'User not found', errorTypes.UNAUTHORIZED); + // Corrected: Pass the errorCode 'USER_NOT_FOUND' + return sendError(res, 'User not found', errorTypes.UNAUTHORIZED, 'USER_NOT_FOUND'); } // Attach user to request req.user = { id: user.id, role: user.user_role, + email: user.user_email, // Added email + organizationId: user.organizationId, // Added organizationId }; next(); } catch (error) { + // Log the error for debugging purposes + // console.error('Authentication error details:', error); + if (error instanceof AppError) { - return sendError(res, error.message, error.statusCode); + return sendError(res, error.message, error.statusCode, error.errorCode); + } + // Handle specific JWT errors to provide clearer messages + if (error instanceof jwt.JsonWebTokenError) { + return sendError(res, 'Invalid token', errorTypes.UNAUTHORIZED, 'INVALID_TOKEN'); + } + if (error instanceof jwt.TokenExpiredError) { + return sendError(res, 'Token expired', errorTypes.UNAUTHORIZED, 'TOKEN_EXPIRED'); } - return sendError(res, 'Authentication failed', errorTypes.UNAUTHORIZED); + // Fallback for other unexpected errors + return sendError( + res, + 'Authentication failed due to an unexpected server error', + errorTypes.INTERNAL_SERVER, + 'AUTH_UNEXPECTED_ERROR', + ); } }; diff --git a/backend/src/middleware/error.middleware.ts b/backend/src/middleware/error.middleware.ts new file mode 100644 index 0000000..6807424 --- /dev/null +++ b/backend/src/middleware/error.middleware.ts @@ -0,0 +1,24 @@ +// Example of what your src/middleware/error.middleware.ts might contain: +import { Request, Response, NextFunction } from 'express'; +import { AppError } from '../utils/errors'; +import { sendError } from '../utils/response'; // Assuming sendError is in utils/response.ts +import { logger } from '../utils/logger'; // Assuming logger is in utils/logger.ts + +export const errorHandler = ( + err: Error | AppError, + req: Request, + res: Response, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + next: NextFunction, // Express needs this signature for error handlers +): void => { + if (err instanceof AppError) { + logger.warn( + `AppError: ${err.statusCode} - ${err.message} - errorCode: ${err.errorCode ?? 'N/A'}`, + ); + sendError(res, err.message, err.statusCode, err.errorCode); + } else { + // Log unexpected errors + logger.error('Unexpected error:', err); + sendError(res, 'An unexpected internal server error occurred.', 500, 'INTERNAL_SERVER_ERROR'); + } +}; diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 34270db..4a3f89c 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -13,20 +13,52 @@ const prisma = new PrismaClient(); const redis = config.REDIS_URL ? new Redis(config.REDIS_URL) : null; interface LoginResponse { - accessToken: string; user: Omit; + accessToken: string; // Corrected: was 'token' which caused an error, should be accessToken + refreshToken: string; // Added refreshToken } +// Ensure RegisterData interface is defined correctly, possibly in a shared types file or here interface RegisterData { + user_name: string; user_email: string; user_password: string; - user_name: string; + organizationId?: string | null; // Added organizationId +} + +// Helper function to parse duration string to seconds +function parseDurationToSeconds(durationStr: string): number { + const regex = /^(\d+)([smhd])$/; + const match = regex.exec(durationStr); + if (!match) { + // Default to 7 days in seconds if parsing fails or format is unexpected + logger.warn(`Invalid duration string: ${durationStr}. Defaulting to 7 days.`); + return 7 * 24 * 60 * 60; + } + + const value = parseInt(match[1]); + const unit = match[2]; + + switch (unit) { + case 's': + return value; + case 'm': + return value * 60; + case 'h': + return value * 60 * 60; + case 'd': + return value * 24 * 60 * 60; + default: + // Should not happen due to regex, but as a fallback + logger.warn(`Unknown duration unit: ${unit} in ${durationStr}. Defaulting to 7 days.`); + return 7 * 24 * 60 * 60; + } } export class AuthService { // User registration async register(userData: RegisterData): Promise> { - const { user_email, user_password, user_name } = userData; + const { user_email, user_password, user_name, organizationId } = userData; // Check if user already exists const existingUser = await prisma.user.findFirst({ @@ -40,6 +72,15 @@ export class AuthService { // Hash password const hashedPassword = await hashPassword(user_password); + if (organizationId) { + const organization = await prisma.organization.findUnique({ + where: { id: organizationId }, + }); + if (!organization) { + throw new AppError('Organization not found', errorTypes.BAD_REQUEST); + } + } + // Create new user const newUser = await prisma.user.create({ data: { @@ -47,6 +88,7 @@ export class AuthService { user_password: hashedPassword, user_name, user_role: 'USER', // Default role + organizationId: organizationId ?? undefined, // Use undefined if schema expects optional, or null if nullable }, }); @@ -75,18 +117,23 @@ export class AuthService { } // Generate tokens - const tokenPayload = { userId: user.id, role: user.user_role }; + const tokenPayload = { + userId: user.id, + role: user.user_role, + organizationId: user.organizationId, + }; const accessToken = generateAccessToken(tokenPayload); - const refreshToken = generateRefreshToken(tokenPayload); + const refreshTokenValue = generateRefreshToken(tokenPayload); // Renamed to avoid conflict // Store refresh token in Redis if available if (redis) { const tokenId = uuidv4(); + const expirySeconds = parseDurationToSeconds(config.REFRESH_TOKEN_EXPIRES_IN); await redis.set( `refresh_token:${user.id}:${tokenId}`, - refreshToken, + refreshTokenValue, 'EX', - 60 * 60 * 24 * 7, // 7 days + expirySeconds, ); } @@ -94,7 +141,7 @@ export class AuthService { const { user_password: _, ...userWithoutPassword } = user; logger.info(`User logged in: ${email}`); - return { accessToken, user: userWithoutPassword }; + return { user: userWithoutPassword, accessToken: accessToken, refreshToken: refreshTokenValue }; } // Refresh access token @@ -130,12 +177,9 @@ export class AuthService { async logout(userId: string, refreshToken: string): Promise { if (redis) { // Add refresh token to blacklist until its expiration - await redis.set( - `blacklist:${refreshToken}`, - '1', - 'EX', - 60 * 60 * 24 * 7, // 7 days - ); + // Use the same parsing logic for consistency if REFRESH_TOKEN_EXPIRES_IN is used for blacklist duration + const blacklistExpirySeconds = parseDurationToSeconds(config.REFRESH_TOKEN_EXPIRES_IN); + await redis.set(`blacklist:${refreshToken}`, '1', 'EX', blacklistExpirySeconds); // Remove all refresh tokens for this user (optional, for complete logout) const keys = await redis.keys(`refresh_token:${userId}:*`); diff --git a/backend/src/tests/authorization/permissionChecker.test.ts b/backend/src/tests/authorization/permissionChecker.test.ts new file mode 100644 index 0000000..312f7c9 --- /dev/null +++ b/backend/src/tests/authorization/permissionChecker.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect } from '@jest/globals'; +import { + hasPermission, + hasAnyPermission, + getUserPermissions, +} from '../../authorization/utils/permissionChecker'; +import { createTestUser, createTestOrganization } from '../utils/authTestUtils'; +import { USER_PERMISSIONS, ADMIN_PERMISSIONS } from '../../authorization/constants/permissions'; // Import constants +import { UserRole } from '@prisma/client'; + +describe('Permission Checker', () => { + describe('hasPermission', () => { + test('should return true for valid user permission', async () => { + const testUser = await createTestUser({ + user_email: `user-perm-${Date.now()}@example.com`, + user_role: UserRole.USER, + }); + const authUser = { ...testUser, user_role: testUser.user_role }; + const result = hasPermission(authUser, USER_PERMISSIONS.READ_SELF); + expect(result).toBe(true); + }); + + test('should return false for invalid user permission', async () => { + const testUser = await createTestUser({ + user_email: `user-invalid-perm-${Date.now()}@example.com`, + user_role: UserRole.USER, + }); + const authUser = { ...testUser, user_role: testUser.user_role }; + const result = hasPermission(authUser, ADMIN_PERMISSIONS.DELETE_USERS); + expect(result).toBe(false); + }); + + test('should return true for admin permission', async () => { + const org = await createTestOrganization(`Org-AdminPerm-${Date.now()}`); + const testUser = await createTestUser({ + user_email: `admin-perm-${Date.now()}@example.com`, + user_role: UserRole.ADMIN, + organizationId: org.id, + }); + const authUser = { ...testUser, user_role: testUser.user_role, organizationId: org.id }; + const result = hasPermission(authUser, ADMIN_PERMISSIONS.READ_USERS); + expect(result).toBe(true); + }); + }); + + describe('hasAnyPermission', () => { + test('should return true if user has any of the permissions', async () => { + const testUser = await createTestUser({ + user_email: `user-anyperm-${Date.now()}@example.com`, + user_role: UserRole.USER, + }); + const authUser = { ...testUser, user_role: testUser.user_role }; + const result = hasAnyPermission(authUser, [ + USER_PERMISSIONS.READ_SELF, + ADMIN_PERMISSIONS.DELETE_USERS, + ]); + expect(result).toBe(true); + }); + }); + + describe('getUserPermissions', () => { + test('should return correct permissions for user role', async () => { + const testUser = await createTestUser({ + user_email: `user-getperm-${Date.now()}@example.com`, + user_role: UserRole.USER, + }); + const authUser = { ...testUser, user_role: testUser.user_role }; + const permissions = getUserPermissions(authUser); + expect(permissions).toContain(USER_PERMISSIONS.READ_SELF); + expect(permissions).toContain(USER_PERMISSIONS.CREATE_BOOKING); + expect(permissions).not.toContain(ADMIN_PERMISSIONS.DELETE_USERS); + }); + }); +}); diff --git a/backend/src/tests/middleware/auth.middleware.test.ts b/backend/src/tests/middleware/auth.middleware.test.ts new file mode 100644 index 0000000..5f02b13 --- /dev/null +++ b/backend/src/tests/middleware/auth.middleware.test.ts @@ -0,0 +1,128 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { Request, Response as ExpressResponse } from 'express'; // Alias ExpressResponse +import { authenticate, checkRole } from '../../middleware/auth.middleware'; +import { + createTestUser, + generateTestToken, + createMockResponse, + createMockNext, + MockResponse, + createTestOrganization, + TestUser, // Import TestUser type +} from '../utils/authTestUtils'; +import { UserRole, Organization } from '@prisma/client'; // Import Organization + +describe('Authentication Middleware', () => { + let mockReq: Partial; + let mockRes: MockResponse; + let mockNext: jest.Mock; + let testOrg: Organization; // To hold created organization + + beforeEach(async () => { + mockReq = {}; + mockRes = createMockResponse(); + mockNext = createMockNext(); + // Create a common organization for tests in this suite + testOrg = await createTestOrganization(`AuthMiddlewareTestOrg-${Date.now()}`); + }); + + describe('authenticate middleware', () => { + test('should authenticate valid JWT token', async () => { + const testUser = await createTestUser({ + user_email: `auth-mid-user-${Date.now()}@example.com`, + user_role: UserRole.USER, + organizationId: testOrg.id, // Provide organizationId + }); + const token = generateTestToken(testUser); + mockReq.headers = { authorization: `Bearer ${token}` }; + + await authenticate(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockReq.user).toBeDefined(); + expect(mockReq.user?.id).toBe(testUser.id); + expect(mockReq.user?.organizationId).toBe(testOrg.id); + }); + + test('should return 401 if no token is provided', async () => { + mockReq.headers = {}; + + await authenticate(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); // Assert on MockResponse + expect(mockNext).not.toHaveBeenCalled(); + }); + + test('should return 401 for invalid token', async () => { + mockReq.headers = { authorization: 'Bearer invalid-token' }; + + await authenticate(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); // Assert on MockResponse + expect(mockNext).not.toHaveBeenCalled(); + }); + + test('should return 401 if user not found', async () => { + // Create a user payload for a token, but don't create the user in DB + const nonExistentUserPayload: TestUser = { + // Use TestUser type + id: 'non-existent-user-id', + user_name: 'Ghost User', // Add user_name as it's part of TestUser + user_email: `ghost-${Date.now()}@example.com`, + user_role: UserRole.USER, + organizationId: testOrg.id, + }; + const token = generateTestToken(nonExistentUserPayload); // No 'as any' needed + mockReq.headers = { authorization: `Bearer ${token}` }; + + await authenticate(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + expect(mockNext).not.toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(401); + // Adjust to match the actual error response structure from sendError + expect(mockRes.json).toHaveBeenCalledWith({ + success: false, + error: expect.objectContaining({ + message: 'User not found', + code: 'USER_NOT_FOUND', + }), + }); + }); + }); + + describe('checkRole middleware', () => { + test('should allow access for correct role', async () => { + const adminUser = await createTestUser({ + user_email: `auth-mid-admin-${Date.now()}@example.com`, + user_role: UserRole.ADMIN, + organizationId: testOrg.id, // Provide organizationId + }); + mockReq.user = { + id: adminUser.id, + role: adminUser.user_role, + organizationId: adminUser.organizationId, + }; + const adminOnlyMiddleware = checkRole([UserRole.ADMIN]); + + adminOnlyMiddleware(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + expect(mockNext).toHaveBeenCalled(); + }); + + test('should reject access for incorrect role', async () => { + const regularUser = await createTestUser({ + user_email: `auth-mid-norole-${Date.now()}@example.com`, + user_role: UserRole.USER, + organizationId: testOrg.id, // Provide organizationId + }); + mockReq.user = { + id: regularUser.id, + role: regularUser.user_role, + organizationId: regularUser.organizationId, + }; + const adminOnlyMiddleware = checkRole([UserRole.ADMIN]); + + adminOnlyMiddleware(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + expect(mockRes.status).toHaveBeenCalledWith(403); // Or 403 for Forbidden + expect(mockNext).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/tests/middleware/rbacMiddleware.test.ts b/backend/src/tests/middleware/rbacMiddleware.test.ts new file mode 100644 index 0000000..c68f61a --- /dev/null +++ b/backend/src/tests/middleware/rbacMiddleware.test.ts @@ -0,0 +1,203 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import { Request, Response as ExpressResponse } from 'express'; // Alias ExpressResponse +import { + requirePermissions, + requireSameOrganization, +} from '../../authorization/middleware/rbacMiddleware'; +import { + createTestUser, + createTestOrganization, + createMockResponse, + createMockNext, + MockResponse, // Import your MockResponse type +} from '../utils/authTestUtils'; +import { UserRole, Organization } from '@prisma/client'; // Add Organization type +import { ALL_PERMISSIONS } from '../../authorization/constants/permissions'; + +describe('RBAC Middleware', () => { + let mockReq: Partial; + let mockRes: MockResponse; + let mockNext: jest.Mock; + // Outer beforeEach for common mocks + beforeEach(() => { + mockReq = {}; + mockRes = createMockResponse(); + mockNext = createMockNext(); + }); + + describe('requirePermissions middleware', () => { + let org: Organization; // Define org here + + beforeEach(async () => { + // This beforeEach is specific to this describe block + org = await createTestOrganization('Test Org For Permissions'); + }); + + test('should allow access with required permissions', async () => { + const testUser = await createTestUser({ + user_email: `admin-permissions-${Date.now()}@example.com`, // Ensure unique email + user_role: UserRole.ADMIN, + organizationId: org.id, // Use the created org's ID + }); + + mockReq.user = { + id: testUser.id, + role: testUser.user_role, + organizationId: testUser.organizationId, + }; + + const permissionMiddleware = requirePermissions([ALL_PERMISSIONS.READ_USERS]); + permissionMiddleware(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalledWith(expect.any(Error)); + }); + + test('should deny access without required permissions', async () => { + const testUser = await createTestUser({ + user_email: `user-permissions-${Date.now()}@example.com`, // Ensure unique email + user_role: UserRole.USER, + organizationId: org.id, // Use the created org's ID + }); + + mockReq.user = { + id: testUser.id, + role: testUser.user_role, + organizationId: testUser.organizationId, + }; + + const permissionMiddleware = requirePermissions([ALL_PERMISSIONS.READ_USERS]); + permissionMiddleware(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe('requireSameOrganization middleware', () => { + let organization: Organization; // Use Organization type + + beforeEach(async () => { + // This organization is created for each test within this describe block + organization = await createTestOrganization(`Test Org Same Org ${Date.now()}`); + }); + + test('should allow access if user is in the same organization as target in params', async () => { + const testUser = await createTestUser({ + user_email: `user-same-org-${Date.now()}@example.com`, + user_role: UserRole.USER, + organizationId: organization.id, + }); + + mockReq.user = { + id: testUser.id, + role: testUser.user_role, + organizationId: testUser.organizationId, + }; + mockReq.params = { organizationId: organization.id }; + + const orgMiddleware = requireSameOrganization(); + // Cast mockRes when passing to the middleware + orgMiddleware(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).not.toHaveBeenCalledWith(expect.any(Error)); + }); + + test('should deny access if user is in a different organization', async () => { + const otherOrganization = await createTestOrganization(`Other Org ${Date.now()}`); + const testUser = await createTestUser({ + user_email: `user-diff-org-${Date.now()}@example.com`, + user_role: UserRole.USER, + organizationId: organization.id, // User belongs to 'organization' + }); + + mockReq.user = { + id: testUser.id, + role: testUser.user_role, + organizationId: testUser.organizationId, + }; + mockReq.params = { organizationId: otherOrganization.id }; + + const orgMiddleware = requireSameOrganization(); + // Cast mockRes when passing to the middleware + orgMiddleware(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + + expect(mockNext).toHaveBeenCalledWith( + expect.objectContaining({ + status: 403, + message: 'User does not belong to the target organization', + }), + ); + }); + + test('should deny access if target organizationId is missing from params', async () => { + const testUser = await createTestUser({ + user_email: `user-no-param-org-${Date.now()}@example.com`, + user_role: UserRole.USER, + organizationId: organization.id, + }); + + mockReq.user = { + id: testUser.id, + role: testUser.user_role, + organizationId: testUser.organizationId, + }; + mockReq.params = {}; + + const orgMiddleware = requireSameOrganization(); + // Cast mockRes when passing to the middleware + orgMiddleware(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + + expect(mockNext).toHaveBeenCalledWith( + expect.objectContaining({ + status: 400, + message: + "Target organization ID not found in request parameters using key: 'organizationId'", + }), + ); + }); + + test('should deny access if authenticated user has no organizationId', async () => { + // Assuming User.organizationId is nullable. If not, this test needs adjustment. + const testUser = await createTestUser({ + user_email: `user-no-orgid-${Date.now()}@example.com`, + user_role: UserRole.USER, + organizationId: null, // Explicitly set to null if schema allows + }); + + mockReq.user = { + id: testUser.id, + role: testUser.user_role, + organizationId: null, + }; + mockReq.params = { organizationId: organization.id }; + + const orgMiddleware = requireSameOrganization(); + // Cast mockRes when passing to the middleware + orgMiddleware(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + + expect(mockNext).toHaveBeenCalledWith( + expect.objectContaining({ + status: 403, + message: 'User does not belong to any organization', + }), + ); + }); + + test('should deny access if user is not authenticated', async () => { + mockReq.user = undefined; + mockReq.params = { organizationId: organization.id }; + + const orgMiddleware = requireSameOrganization(); + // Cast mockRes when passing to the middleware + orgMiddleware(mockReq as Request, mockRes as unknown as ExpressResponse, mockNext); + + expect(mockNext).toHaveBeenCalledWith( + expect.objectContaining({ + status: 401, + message: 'Authentication required', + }), + ); + }); + }); +}); diff --git a/backend/src/tests/routes/auth.routes.test.ts b/backend/src/tests/routes/auth.routes.test.ts new file mode 100644 index 0000000..8fd73a1 --- /dev/null +++ b/backend/src/tests/routes/auth.routes.test.ts @@ -0,0 +1,139 @@ +import { describe, test, expect } from '@jest/globals'; +import request from 'supertest'; +import express from 'express'; +import authRoutes from '../../routes/auth.routes'; +import { createTestOrganization } from '../utils/authTestUtils'; +import { errorHandler } from '../../middleware/error.middleware'; // Assuming this is your error handler + +const app = express(); +app.use(express.json()); +app.use('/auth', authRoutes); +app.use(errorHandler); // Use your imported error handling middleware + +describe('Auth Routes Integration Tests', () => { + const complexPassword = 'Password123!'; // Meets complexity + + describe('POST /auth/register', () => { + test('should register new user successfully', async () => { + const organization = await createTestOrganization(`Org-Reg-Route-${Date.now()}`); + const userData = { + firstName: 'Test', + lastName: 'User Route', + email: `newuser-route-${Date.now()}@example.com`, + password: complexPassword, // Use complex password + organizationId: organization.id, + }; + + const response = await request(app).post('/auth/register').send(userData).expect(201); + + expect(response.body.data.user).toBeDefined(); + expect(response.body.data.user.user_email).toBe(userData.email); + }); + + test('should reject registration with invalid email', async () => { + const userData = { + firstName: 'Test', + lastName: 'User', + email: 'invalid-email', // This will be caught by Zod + password: complexPassword, // Use complex password + }; + + await request(app).post('/auth/register').send(userData).expect(400); + }); + }); + + describe('POST /auth/login', () => { + test('should login with valid credentials', async () => { + const org = await createTestOrganization(`Org-Login-Route-${Date.now()}`); + const userEmail = `login-route-${Date.now()}@example.com`; + // const userPassword = 'password123Secure'; // Old password + + // Register the user first + await request(app) + .post('/auth/register') + .send({ + firstName: 'Login', + lastName: 'Test', + email: userEmail, + password: complexPassword, // Use complex password + organizationId: org.id, + }) + .expect(201); + + const loginData = { + email: userEmail, + password: complexPassword, // Use complex password + }; + + const response = await request(app).post('/auth/login').send(loginData).expect(200); + + expect(response.body.data.user).toBeDefined(); + expect(response.body.data.accessToken).toBeDefined(); + expect(response.body.data.user.user_email).toBe(loginData.email); + // Check for HttpOnly cookie for refreshToken + const setCookieHeader = response.headers['set-cookie']; + expect(setCookieHeader).toBeDefined(); + + let cookieArray: string[]; + if (typeof setCookieHeader === 'string') { + cookieArray = [setCookieHeader]; + } else if (Array.isArray(setCookieHeader)) { + cookieArray = setCookieHeader; + } else { + // This case should ideally not be reached if expect(setCookieHeader).toBeDefined() passes + // and set-cookie is either string or string[] + cookieArray = []; + } + + expect(cookieArray.some((cookie: string) => cookie.startsWith('refreshToken='))).toBe(true); + }); + + test('should reject login with invalid credentials', async () => { + const loginData = { + email: 'nonexistent@example.com', + password: 'WrongPassword1!', // Use a complex but wrong password + }; + + await request(app).post('/auth/login').send(loginData).expect(401); + }); + }); + + describe('POST /auth/logout', () => { + test('should logout authenticated user', async () => { + const org = await createTestOrganization(`Org-Logout-Route-${Date.now()}`); + const userEmail = `logout-route-${Date.now()}@example.com`; + // const userPassword = 'password123Secure'; // Old password + + // Register user + await request(app) + .post('/auth/register') + .send({ + firstName: 'Logout', + lastName: 'Test', + email: userEmail, + password: complexPassword, // Use complex password + organizationId: org.id, + }) + .expect(201); + + // Login to get token and cookies + const loginResponse = await request(app).post('/auth/login').send({ + email: userEmail, + password: complexPassword, // Use complex password + }); + + const token = loginResponse.body.data.accessToken; + expect(token).toBeDefined(); + + const agent = request.agent(app); + // Login with agent to establish session and cookies for the agent + await agent.post('/auth/login').send({ email: userEmail, password: complexPassword }); + + await agent.post('/auth/logout').set('Authorization', `Bearer ${token}`).expect(200); + }); + + test('should reject logout without authentication', async () => { + await request(app).post('/auth/logout').expect(401); + }); + }); +}); diff --git a/backend/src/tests/setup/testSetup.ts b/backend/src/tests/setup/testSetup.ts new file mode 100644 index 0000000..604f9ca --- /dev/null +++ b/backend/src/tests/setup/testSetup.ts @@ -0,0 +1,104 @@ +import { PrismaClient } from '@prisma/client'; +import { beforeAll, afterAll, beforeEach } from '@jest/globals'; +import originalLoggerInstance from '../../utils/logger'; // Import the actual logger instance + +// Define an interface for the logger methods used in this file. +// This ensures that both the original Winston logger and the fallback are compatible. +interface TestSetupLogger { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + debug: (...args: unknown[]) => void; // Included for consistency with the fallback +} + +// Type 'logger' with TestSetupLogger. +// The originalLoggerInstance (Winston logger) is compatible because it has these methods. +let logger: TestSetupLogger = originalLoggerInstance; + +if (!logger || typeof logger.info !== 'function') { + // Use console.warn directly here, as 'logger' might be null/undefined or not have a .warn method + // eslint-disable-next-line no-console + console.warn( + 'Original logger not found or not standard in testSetup.ts, using console fallback.', + ); + // The fallback logger also conforms to TestSetupLogger. + logger = { + // eslint-disable-next-line no-console + info: (...args: unknown[]): void => console.log(...args), + // eslint-disable-next-line no-console + warn: (...args: unknown[]): void => console.warn(...args), + // eslint-disable-next-line no-console + error: (...args: unknown[]): void => console.error(...args), + // eslint-disable-next-line no-console + debug: (...args: unknown[]): void => console.log(...args), + }; +} + +const prisma = new PrismaClient({ + datasources: { + db: { + url: process.env.DATABASE_URL_TEST ?? process.env.DATABASE_URL, + }, + }, + // log: ['query', 'info', 'warn', 'error'], // Optional: for more detailed logs during tests +}); + +let isConnected = false; + +beforeAll(async () => { + // Only attempt to connect if DATABASE_URL_TEST is explicitly set, + // indicating a dedicated test database environment is intended. + if (process.env.DATABASE_URL_TEST) { + try { + await prisma.$connect(); + isConnected = true; + logger.info('Successfully connected to test database (DATABASE_URL_TEST).'); + } catch (error) { + isConnected = false; + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error( + `Failed to connect to test database (DATABASE_URL_TEST) in global setup: ${errorMessage}. Integration tests requiring DB will likely fail.`, + ); + // Do not rethrow here, to allow tests that mock Prisma to run. + } + } else { + logger.warn( + 'DATABASE_URL_TEST is not set. Global Prisma client in testSetup.ts will not attempt to connect. ' + + 'Database-dependent integration tests should ensure their own DB setup or use mocks. ' + + 'Operations in authTestUtils using this global prisma instance may fail if they expect a connection.', + ); + } +}); + +afterAll(async () => { + if (isConnected) { + await prisma.$disconnect(); + isConnected = false; // Reset status + logger.info('Disconnected from test database.'); + } +}); + +beforeEach(async () => { + if (isConnected) { + // Clean up database before each test only if connected to a test DB + try { + // Order of deletion matters due to foreign key constraints + // Adjust order as per your actual schema dependencies + await prisma.booking.deleteMany({}); + await prisma.waitlist.deleteMany({}); + await prisma.notification.deleteMany({}); + await prisma.timeSlot.deleteMany({}); + await prisma.lab.deleteMany({}); + await prisma.admin.deleteMany({}); + await prisma.superAdmin.deleteMany({}); + await prisma.user.deleteMany({}); + await prisma.organizationNotification.deleteMany({}); + await prisma.organization.deleteMany({}); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Failed to clean test database in beforeEach: ${errorMessage}`); + } + } +}); + +export { prisma }; diff --git a/backend/src/tests/utils/authTestUtils.ts b/backend/src/tests/utils/authTestUtils.ts new file mode 100644 index 0000000..8a2ed54 --- /dev/null +++ b/backend/src/tests/utils/authTestUtils.ts @@ -0,0 +1,123 @@ +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import { UserRole, Organization } from '@prisma/client'; // PrismaClient removed +import { Request, CookieOptions } from 'express'; // Import CookieOptions +import { prisma } from '../setup/testSetup'; // Import the shared prisma instance + +export interface TestUser { + id: string; + user_name: string; + user_email: string; + user_role: UserRole; + organizationId?: string | null; +} + +// Interface for the user object attached to the Express Request +interface AuthenticatedUser { + id: string; + role: UserRole; + email: string; + organizationId?: string | null; +} + +export const createTestUser = async ( + userData: Partial & { user_email: string }, +): Promise => { + const hashedPassword = await bcrypt.hash('testpassword123', 10); + + // Use the imported prisma client + const user = await prisma.user.create({ + data: { + user_name: userData.user_name ?? 'Test User', + user_email: userData.user_email, + user_password: hashedPassword, + user_role: userData.user_role ?? UserRole.USER, + organizationId: userData.organizationId, + }, + }); + return user; +}; + +export const createTestOrganization = async ( + name: string = 'Test Organization', +): Promise => { + // Use the imported prisma client + return await prisma.organization.create({ + data: { + org_name: name, + org_type: 'Educational', + org_location: 'Default Location', // Ensure all required fields are present + }, + }); +}; + +export const generateTestToken = (user: TestUser): string => { + return jwt.sign( + { + userId: user.id, + email: user.user_email, + role: user.user_role, + organizationId: user.organizationId, + }, + process.env.JWT_SECRET ?? 'test-secret', + { expiresIn: '1h' }, + ); +}; + +export const createAuthenticatedRequest = (user: TestUser): Partial => { + const token = generateTestToken(user); + return { + headers: { + authorization: `Bearer ${token}`, + }, + user: { + id: user.id, + email: user.user_email, + role: user.user_role, + organizationId: user.organizationId, + } as AuthenticatedUser, + }; +}; + +export interface MockResponse { + status: jest.Mock; + json: jest.Mock; + send: jest.Mock; + sendStatus: jest.Mock; + locals: Record; + cookie: jest.Mock; + clearCookie: jest.Mock; + setHeader: jest.Mock; + getHeader: jest.Mock; +} + +export const createMockResponse = (): MockResponse => { + const res = { + locals: {}, + _headers: {}, + } as MockResponse & { _headers?: Record }; + + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.send = jest.fn().mockReturnValue(res); + res.sendStatus = jest.fn().mockReturnValue(res); + res.cookie = jest.fn().mockReturnValue(res); + res.clearCookie = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockImplementation(function ( + this: typeof res, + key: string, + value: string | string[], + ) { + if (this._headers) { + this._headers[key.toLowerCase()] = value; + } + return this; + }); + res.getHeader = jest.fn().mockImplementation(function (this: typeof res, key: string) { + return this._headers ? this._headers[key.toLowerCase()] : undefined; + }); + + return res as MockResponse; +}; + +export const createMockNext = (): jest.Mock => jest.fn(); diff --git a/backend/src/utils/errors.ts b/backend/src/utils/errors.ts index a13e184..39acd4b 100644 --- a/backend/src/utils/errors.ts +++ b/backend/src/utils/errors.ts @@ -2,11 +2,14 @@ export class AppError extends Error { statusCode: number; isOperational: boolean; + errorCode?: string; // Added errorCode - constructor(message: string, statusCode: number) { + constructor(message: string, statusCode: number, errorCode?: string) { + // Added errorCode super(message); this.statusCode = statusCode; this.isOperational = true; + this.errorCode = errorCode; // Store errorCode Error.captureStackTrace(this, this.constructor); } diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts index 6c1de3b..1500b72 100644 --- a/backend/src/utils/logger.ts +++ b/backend/src/utils/logger.ts @@ -33,7 +33,7 @@ if (config.NODE_ENV !== 'test') { } } -const logger = winston.createLogger({ +export const logger = winston.createLogger({ level: config.LOG_LEVEL, format: winston.format.combine( winston.format.timestamp(), diff --git a/docs/Implementation/Development Plan Complete.md b/docs/Implementation/Development Plan Complete.md index 9f64682..07e14a5 100644 --- a/docs/Implementation/Development Plan Complete.md +++ b/docs/Implementation/Development Plan Complete.md @@ -116,10 +116,10 @@ Corresponds to Backend Plan - PHASE 1 (Day 3 Completion), PHASE 2, PHASE 3 (Day * [x] Task 2.3: Context-Aware Authorization * [x] Task 2.4: Testing Authorization Logic * **Day 3: Authorization Utilities & Integration** - * [ ] Task 3.1: Permission Checking Utilities - * [ ] Task 3.2: Audit Logging - * [ ] Task 3.3: Route Protection Implementation - * [ ] Task 3.4: Testing & Documentation + * [x] Task 3.1: Permission Checking Utilities + * [x] Task 3.2: Audit Logging + * [x] Task 3.3: Route Protection Implementation + * [x] Task 3.4: Testing & Documentation * **Base Service Layer & API Infrastructure** * **Base Repository Pattern (Backend PHASE 3 - Day 1)** * [ ] Task 1.1: Generic Repository Interface diff --git a/docs/Implementation/Develpment Plan Backend.md b/docs/Implementation/Develpment Plan Backend.md index 16ef005..d9a5d3d 100644 --- a/docs/Implementation/Develpment Plan Backend.md +++ b/docs/Implementation/Develpment Plan Backend.md @@ -101,22 +101,22 @@ **Day 3: Authorization Utilities & Integration** -* [ ] Task 3.1: Permission Checking Utilities - * [ ] Create reusable permission checking functions. - * [ ] Implement helper methods for common permission patterns. - * [ ] Add caching for frequently checked permissions. -* [ ] Task 3.2: Audit Logging - * [ ] Implement audit logging for sensitive operations. - * [ ] Create structured log format for security events. - * [ ] Ensure PII protection in logs. -* [ ] Task 3.3: Route Protection Implementation - * [ ] Apply role requirements to all defined routes. - * [ ] Group routes by required permission level. - * [ ] Implement resource-specific access controls. -* [ ] Task 3.4: Testing & Documentation - * [ ] Create comprehensive tests for authorization system. - * [ ] Document authorization patterns for developers. - * [ ] Update API documentation with permission requirements. +* [x] Task 3.1: Permission Checking Utilities + * [x] Create reusable permission checking functions. + * [x] Implement helper methods for common permission patterns. + * [x] Add caching for frequently checked permissions. +* [x] Task 3.2: Audit Logging + * [x] Implement audit logging for sensitive operations. + * [x] Create structured log format for security events. + * [x] Ensure PII protection in logs. +* [x] Task 3.3: Route Protection Implementation + * [x] Apply role requirements to all defined routes. + * [x] Group routes by required permission level. + * [x] Implement resource-specific access controls. +* [x] Task 3.4: Testing & Documentation + * [x] Create comprehensive tests for authorization system. + * [x] Document authorization patterns for developers. + * [x] Update API documentation with permission requirements. ## PHASE 3: PRISMA SERVICE LAYER (7 DAYS) diff --git a/package-lock.json b/package-lock.json index 65e5843..293d2ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.12.12", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "eslint": "^8.56.0", @@ -61,6 +62,7 @@ "jest-mock-extended": "^4.0.0-beta1", "prettier": "^3.2.5", "prisma": "^6.8.2", + "supertest": "^7.1.1", "ts-jest": "^29.1.4", "ts-node": "^10.9.1", "ts-node-dev": "^2.0.0", @@ -2870,6 +2872,19 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2908,6 +2923,16 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4028,6 +4053,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.17", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", @@ -4166,6 +4198,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4292,6 +4331,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -5163,6 +5226,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -6100,6 +6170,16 @@ "node": ">=18" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6183,6 +6263,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -6581,6 +6668,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -7844,6 +7942,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -8075,6 +8180,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -13854,6 +13977,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.1.tgz", + "integrity": "sha512-O+PCv11lgTNJUzy49teNAWLjBZfc+A1enOwTpLlH6/rsvKcTwcdTT8m9azGkVqM7HBl5jpyZ7KTPhHweokBcdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.1.tgz", + "integrity": "sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",