Skip to content
Open

Dev #76

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
bfbbbfa
Merge pull request #74 from AdityaDRathore/main
AdityaDRathore Jun 6, 2025
a492ff1
Add test setup file for Prisma client with database connection and cl…
AdityaDRathore Jun 7, 2025
624949f
Add integration tests for authentication routes
AdityaDRathore Jun 7, 2025
0d5f0ee
Add utility functions for testing authentication and user management
AdityaDRathore Jun 7, 2025
c724f44
Add tests for RBAC middleware functionality
AdityaDRathore Jun 7, 2025
ef6563e
Add tests for authentication and role-checking middleware
AdityaDRathore Jun 7, 2025
f401cc9
Add tests for permission checking functionality
AdityaDRathore Jun 7, 2025
d4bfe15
Add email and organizationId to user object in authentication middleware
AdityaDRathore Jun 7, 2025
86b2e63
Add requireSameOrganization middleware and getUserPermissions utility…
AdityaDRathore Jun 7, 2025
8c9202e
Refactor Jest configuration for improved test matching and coverage c…
AdityaDRathore Jun 7, 2025
6ba475e
Add supertest and its type definitions for enhanced testing capabilities
AdityaDRathore Jun 7, 2025
f7341c8
Fix missing newline at end of test setup file
AdityaDRathore Jun 7, 2025
07cf029
Format error message in requireSameOrganization middleware for improv…
AdityaDRathore Jun 7, 2025
3ad9c15
Fix missing comma in import statement and add newline at end of permi…
AdityaDRathore Jun 7, 2025
a23828f
Refactor auth middleware tests to use MockResponse type and improve t…
AdityaDRathore Jun 7, 2025
a066313
Refactor RBAC middleware tests to use MockResponse type and improve t…
AdityaDRathore Jun 7, 2025
db59a96
Refactor auth routes tests for improved readability by consolidating …
AdityaDRathore Jun 7, 2025
7540014
Refactor authTestUtils to enhance type safety and improve mock respon…
AdityaDRathore Jun 7, 2025
b288a5d
Fix formatting in rbacMiddleware test by removing unnecessary newline
AdityaDRathore Jun 7, 2025
6ae15b3
Update JWT_SECRET defaults in environment config to match authTestUtils
AdityaDRathore Jun 7, 2025
c6d87b5
Enhance validation and error handling in AuthController; add min leng…
AdityaDRathore Jun 7, 2025
6ce3f65
Enhance error handling in authentication middleware; include specific…
AdityaDRathore Jun 7, 2025
009cb96
Add error handling middleware to manage application errors and log un…
AdityaDRathore Jun 7, 2025
5458f71
Refactor AuthService to include organizationId in registration and lo…
AdityaDRathore Jun 7, 2025
b699b79
Refactor permission tests to use constants for permissions and improv…
AdityaDRathore Jun 7, 2025
48ac360
Refactor authentication middleware tests to improve organization hand…
AdityaDRathore Jun 7, 2025
13c203d
Refactor RBAC middleware tests to improve organization handling and e…
AdityaDRathore Jun 7, 2025
2384177
Refactor auth routes tests to improve organization handling, enhance …
AdityaDRathore Jun 7, 2025
27a9a48
Refactor test setup to improve logger handling, enhance database conn…
AdityaDRathore Jun 7, 2025
bf4c7fc
Refactor auth test utilities to remove direct PrismaClient instantiat…
AdityaDRathore Jun 7, 2025
a2fd7f3
Enhance error handling by adding optional errorCode parameter to AppE…
AdityaDRathore Jun 7, 2025
3db41eb
Refactor error handling and improve organization in middleware and te…
AdityaDRathore Jun 7, 2025
88cb2ad
Testsetup passes linting and formatting checks
AdityaDRathore Jun 7, 2025
28836e6
Complete tasks for Day 3: Authorization Utilities & Integration in th…
AdityaDRathore Jun 7, 2025
ddd5757
Merge pull request #75 from AdityaDRathore/feature/db_test
AdityaDRathore Jun 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/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: ['<rootDir>/src/tests/setup/testSetup.ts'],
testMatch: [
'<rootDir>/src/**/__tests__/**/*.test.ts',
'<rootDir>/src/tests/**/*.test.ts'
],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/tests/**',
'!src/**/__tests__/**'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
modulePathIgnorePatterns: ['<rootDir>/dist/'],
testTimeout: 10000,
};
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions backend/src/authorization/middleware/rbacMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
};
150 changes: 70 additions & 80 deletions backend/src/authorization/middleware/resourceAccessMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,77 @@ export const requireResourceAccess = (
};
};

// Helper function to get organization ID for a resource
const getResourceOrganizationId = async (
resourceType: ResourceType,
resourceId: string,
): Promise<string | null | undefined> => {
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<void> => {
// Added return type Promise<void>
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'));
Expand All @@ -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'),
);
Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions backend/src/authorization/utils/permissionChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions backend/src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 ?? '*',
};
34 changes: 21 additions & 13 deletions backend/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -37,34 +38,41 @@ 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
}
}

// Login user
async login(req: Request, res: Response, next: NextFunction): Promise<void | Response> {
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(
Expand Down
Loading
Loading