diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faade0ec..66ca8302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ jobs: runs-on: ubuntu-latest env: DATABASE_URL: ${{ secrets.DATABASE_URL_TEST }} + DIRECT_URL: ${{ secrets.DATABASE_URL_TEST }} NODE_ENV: test steps: - name: Checkout repository @@ -30,7 +31,11 @@ jobs: - name: Generate Prisma Client working-directory: attendance-manager run: npx prisma generate - + + - name: Run Migrations + working-directory: attendance-manager + run: npx prisma migrate deploy + - name: Lint working-directory: attendance-manager run: npm run lint diff --git a/attendance-manager/prisma/migrations/20260328000000_add_role_type_and_voting_to_user/migration.sql b/attendance-manager/prisma/migrations/20260328000000_add_role_type_and_voting_to_user/migration.sql new file mode 100644 index 00000000..cc57e825 --- /dev/null +++ b/attendance-manager/prisma/migrations/20260328000000_add_role_type_and_voting_to_user/migration.sql @@ -0,0 +1,4 @@ +-- Add new RoleType enum values only +ALTER TYPE "RoleType" ADD VALUE IF NOT EXISTS 'SUPER_ADMIN'; +ALTER TYPE "RoleType" ADD VALUE IF NOT EXISTS 'ADMIN'; +ALTER TYPE "RoleType" ADD VALUE IF NOT EXISTS 'SENATOR'; diff --git a/attendance-manager/prisma/migrations/20260328000001_add_role_type_column/migration.sql b/attendance-manager/prisma/migrations/20260328000001_add_role_type_column/migration.sql new file mode 100644 index 00000000..ecc9e71f --- /dev/null +++ b/attendance-manager/prisma/migrations/20260328000001_add_role_type_column/migration.sql @@ -0,0 +1,16 @@ +-- Add roleType column with default MEMBER +ALTER TABLE "User" ADD COLUMN "roleType" "RoleType" NOT NULL DEFAULT 'MEMBER'; + +-- Add isVotingMember column with default false +ALTER TABLE "User" ADD COLUMN "isVotingMember" BOOLEAN NOT NULL DEFAULT false; + +-- Backfill roleType from existing Role table +UPDATE "User" u +SET "roleType" = r."roleType" +FROM "Role" r +WHERE u."roleId" = r."roleId"; + +-- Backfill isVotingMember: true for MEMBER role users +UPDATE "User" +SET "isVotingMember" = true +WHERE "roleType" = 'MEMBER'; diff --git a/attendance-manager/prisma/migrations/20260401000000_remove_none_role_type/migration.sql b/attendance-manager/prisma/migrations/20260401000000_remove_none_role_type/migration.sql new file mode 100644 index 00000000..2e9acb30 --- /dev/null +++ b/attendance-manager/prisma/migrations/20260401000000_remove_none_role_type/migration.sql @@ -0,0 +1,3 @@ +-- Convert any NONE role types to MEMBER +UPDATE "User" SET "roleType" = 'MEMBER' WHERE "roleType"::text = 'NONE'; +UPDATE "Role" SET "roleType" = 'MEMBER' WHERE "roleType"::text = 'NONE'; diff --git a/attendance-manager/prisma/migrations/20260407000000_drop_none_from_role_type_enum/migration.sql b/attendance-manager/prisma/migrations/20260407000000_drop_none_from_role_type_enum/migration.sql new file mode 100644 index 00000000..ebefcf45 --- /dev/null +++ b/attendance-manager/prisma/migrations/20260407000000_drop_none_from_role_type_enum/migration.sql @@ -0,0 +1,21 @@ +-- Convert any remaining NONE role types to MEMBER +UPDATE "User" SET "roleType" = 'MEMBER' WHERE "roleType"::text = 'NONE'; +UPDATE "Role" SET "roleType" = 'MEMBER' WHERE "roleType"::text = 'NONE'; + +-- Recreate the RoleType enum without NONE +-- Drop column defaults that reference the enum first +ALTER TABLE "User" ALTER COLUMN "roleType" DROP DEFAULT; + +-- Create new enum without NONE +CREATE TYPE "RoleType_new" AS ENUM ('SUPER_ADMIN', 'ADMIN', 'SENATOR', 'EBOARD', 'MEMBER'); + +-- Migrate columns to new enum type +ALTER TABLE "User" ALTER COLUMN "roleType" TYPE "RoleType_new" USING "roleType"::text::"RoleType_new"; +ALTER TABLE "Role" ALTER COLUMN "roleType" TYPE "RoleType_new" USING "roleType"::text::"RoleType_new"; + +-- Drop old type and rename +DROP TYPE "RoleType"; +ALTER TYPE "RoleType_new" RENAME TO "RoleType"; + +-- Restore default +ALTER TABLE "User" ALTER COLUMN "roleType" SET DEFAULT 'MEMBER'; diff --git a/attendance-manager/prisma/schema.prisma b/attendance-manager/prisma/schema.prisma index 09f5523d..d6329477 100644 --- a/attendance-manager/prisma/schema.prisma +++ b/attendance-manager/prisma/schema.prisma @@ -23,6 +23,8 @@ model User { firstName String lastName String roleId String + roleType RoleType @default(MEMBER) + isVotingMember Boolean @default(false) password String? // Optional - Supabase handles authentication attendance Attendance[] role Role @relation(fields: [roleId], references: [roleId]) @@ -96,6 +98,9 @@ model VotingRecord { } enum RoleType { + SUPER_ADMIN + ADMIN + SENATOR EBOARD MEMBER } diff --git a/attendance-manager/src/app/api/attendance/[attendanceId]/route.ts b/attendance-manager/src/app/api/attendance/[attendanceId]/route.ts index 63a7ac99..0064c947 100644 --- a/attendance-manager/src/app/api/attendance/[attendanceId]/route.ts +++ b/attendance-manager/src/app/api/attendance/[attendanceId]/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { AttendanceController } from '../../../../attendance/attendance.controller'; +import { requireAuth } from '@/utils/api-auth'; +import { checkCanManageAttendance } from '@/utils/permissions'; /** * Updates an Attendance @@ -12,6 +14,11 @@ export async function PATCH( req: NextRequest, { params }: { params: Promise<{ attendanceId: string }> }, ) { + const { user, error } = await requireAuth(); + if (error) return error; + if (!checkCanManageAttendance(user.roleType)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } try { const { attendanceId } = await params; const data = await req.json(); @@ -38,6 +45,11 @@ export async function DELETE( req: NextRequest, { params }: { params: Promise<{ attendanceId: string }> }, ) { + const { user, error } = await requireAuth(); + if (error) return error; + if (!checkCanManageAttendance(user.roleType)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } try { const { attendanceId } = await params; // Await params await AttendanceController.deleteAttendance(attendanceId); diff --git a/attendance-manager/src/app/api/auth/signup/route.ts b/attendance-manager/src/app/api/auth/signup/route.ts index c472457c..d867aaae 100644 --- a/attendance-manager/src/app/api/auth/signup/route.ts +++ b/attendance-manager/src/app/api/auth/signup/route.ts @@ -74,6 +74,7 @@ export async function POST(request: Request) { lastName, nuid, roleId: finalRoleId, + roleType: RoleType.MEMBER, password: undefined, // Optional - Supabase handles authentication }, include: { diff --git a/attendance-manager/src/app/api/meeting/[id]/route.ts b/attendance-manager/src/app/api/meeting/[id]/route.ts index fec1442b..d7fd96e6 100644 --- a/attendance-manager/src/app/api/meeting/[id]/route.ts +++ b/attendance-manager/src/app/api/meeting/[id]/route.ts @@ -1,4 +1,10 @@ +import { NextResponse } from 'next/server'; import { MeetingController } from '@/meeting/meeting.controller'; +import { requireAuth } from '@/utils/api-auth'; +import { + checkCanEditMeetings, + checkCanManageMeetings, +} from '@/utils/permissions'; export async function GET( request: Request, @@ -12,6 +18,11 @@ export async function PUT( request: Request, { params }: { params: Promise<{ id: string }> }, ) { + const { user, error } = await requireAuth(); + if (error) return error; + if (!checkCanEditMeetings(user.roleType)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const { id } = await params; return MeetingController.updateMeeting(request, { meetingId: id }); } @@ -20,6 +31,11 @@ export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> }, ) { + const { user, error } = await requireAuth(); + if (error) return error; + if (!checkCanManageMeetings(user.roleType)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const { id } = await params; return MeetingController.deleteMeeting({ meetingId: id }); } diff --git a/attendance-manager/src/app/api/meeting/route.ts b/attendance-manager/src/app/api/meeting/route.ts index 38bebc7e..81bbff1a 100644 --- a/attendance-manager/src/app/api/meeting/route.ts +++ b/attendance-manager/src/app/api/meeting/route.ts @@ -1,4 +1,7 @@ +import { NextResponse } from 'next/server'; import { MeetingController } from '@/meeting/meeting.controller'; +import { requireAuth } from '@/utils/api-auth'; +import { checkCanManageMeetings } from '@/utils/permissions'; /** * @swagger * /api/users: @@ -25,5 +28,10 @@ export async function GET() { * description: Missing required fields. */ export async function POST(request: Request) { + const { user, error } = await requireAuth(); + if (error) return error; + if (!checkCanManageMeetings(user.roleType)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } return MeetingController.createMeeting(request); } diff --git a/attendance-manager/src/auth/__tests__/auth-flow.test.ts b/attendance-manager/src/auth/__tests__/auth-flow.test.ts index e880aa17..40e30902 100644 --- a/attendance-manager/src/auth/__tests__/auth-flow.test.ts +++ b/attendance-manager/src/auth/__tests__/auth-flow.test.ts @@ -1,405 +1,404 @@ -import { prisma } from '@/lib/prisma'; -import { createServerSupabaseClient } from '@/lib/supabase-server'; -import { UsersService } from '@/users/users.service'; - -jest.setTimeout(20000); - -// Mock Supabase for integration tests -jest.mock('@/lib/supabase-server', () => ({ - createServerSupabaseClient: jest.fn(), -})); - -// Mock UsersService for signup -jest.mock('@/users/users.service', () => { - const actual = jest.requireActual('@/users/users.service'); - return { - ...actual, - UsersService: { - ...actual.UsersService, - getRoleIdByRoleType: jest.fn(), - }, - }; -}); - -describe('Auth Flow Integration Tests', () => { - let testRoleId: string; - let mockSupabaseClient: any; - const mockSignUp = jest.fn(); - const mockSignIn = jest.fn(); - const mockGetSession = jest.fn(); - const mockSignOut = jest.fn(); - - beforeAll(async () => { - // Create test role - const role = await prisma.role.create({ - data: { roleType: 'MEMBER' }, - }); - testRoleId = role.roleId; - - // Setup Supabase mocks - mockSupabaseClient = { - auth: { - signUp: mockSignUp, - signInWithPassword: mockSignIn, - getSession: mockGetSession, - signOut: mockSignOut, - }, - }; - - (createServerSupabaseClient as jest.Mock).mockResolvedValue( - mockSupabaseClient, - ); - (UsersService.getRoleIdByRoleType as jest.Mock).mockResolvedValue( - testRoleId, - ); - }); - - afterAll(async () => { - await UsersService.deleteRole(testRoleId); - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('Complete Auth Flow: Signup -> Login -> Session', () => { - const testUser = { - email: 'flowtest@example.com', - password: 'password123', - firstName: 'Flow', - lastName: 'Test', - nuid: '001234777', - }; - - const supabaseAuthId = 'test-flow-supabase-id-123'; - - it('should complete full auth flow: signup, login, and session check', async () => { - // Step 1: Signup - mockSignUp.mockResolvedValueOnce({ - data: { - user: { - id: supabaseAuthId, - email: testUser.email, - }, - }, - error: null, - }); - - const signupModule = await import('../../app/api/auth/signup/route'); - const signupPOST = signupModule.POST; - const signupReq = new Request('http://localhost/api/auth/signup', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(testUser), - }); - - const signupResponse = await signupPOST(signupReq); - const signupData = await signupResponse.json(); - - expect(signupResponse.status).toBe(201); - expect(signupData.user).toBeDefined(); - expect(signupData.user.supabaseAuthId).toBe(supabaseAuthId); - expect(signupData.user.email).toBe(testUser.email); - - // Verify user was created in database - const createdUser = await prisma.user.findUnique({ - where: { supabaseAuthId }, - include: { role: true }, - }); - expect(createdUser).toBeDefined(); - expect(createdUser?.email).toBe(testUser.email); - - // Step 2: Login (simulate AuthContext.login behavior) - mockSignIn.mockResolvedValueOnce({ - data: { - user: { - id: supabaseAuthId, - email: testUser.email, - }, - session: { - access_token: 'mock-access-token', - refresh_token: 'mock-refresh-token', - }, - }, - error: null, - }); - - // Simulate login by calling Supabase signInWithPassword - const loginResult = await mockSupabaseClient.auth.signInWithPassword({ - email: testUser.email, - password: testUser.password, - }); - - expect(loginResult.data.user).toBeDefined(); - expect(loginResult.data.user.id).toBe(supabaseAuthId); - expect(loginResult.error).toBeNull(); - - // Step 3: Fetch user profile by supabaseAuthId (simulate AuthContext.loadUserProfile) - const getUserModule = - await import('../../app/api/users/by-supabase-id/[supabaseAuthId]/route'); - const getUserGET = getUserModule.GET; - const getUserReq = new Request( - `http://localhost/api/users/by-supabase-id/${supabaseAuthId}`, - ); - - const getUserResponse = await getUserGET(getUserReq, { - params: Promise.resolve({ supabaseAuthId }), - }); - const userProfile = await getUserResponse.json(); - - expect(getUserResponse.status).toBe(200); - expect(userProfile.supabaseAuthId).toBe(supabaseAuthId); - expect(userProfile.email).toBe(testUser.email); - expect(userProfile.role).toBeDefined(); - expect(userProfile.role.roleType).toBe('MEMBER'); - - // Step 4: Session check (simulate middleware/AuthContext session check) - mockGetSession.mockResolvedValueOnce({ - data: { - session: { - user: { - id: supabaseAuthId, - email: testUser.email, - }, - access_token: 'mock-access-token', - }, - }, - error: null, - }); - - const sessionResult = await mockSupabaseClient.auth.getSession(); - expect(sessionResult.data.session).toBeDefined(); - expect(sessionResult.data.session.user.id).toBe(supabaseAuthId); - - // Step 5: Verify authenticated user can be retrieved - const apiAuthModule = await import('../../utils/api-auth'); - const getAuthenticatedUser = apiAuthModule.getAuthenticatedUser; - - // Mock getSession for getAuthenticatedUser - mockGetSession.mockResolvedValueOnce({ - data: { - session: { - user: { - id: supabaseAuthId, - email: testUser.email, - }, - }, - }, - error: null, - }); - - const authenticatedUser = await getAuthenticatedUser(); - expect(authenticatedUser).toBeDefined(); - expect(authenticatedUser?.supabaseAuthId).toBe(supabaseAuthId); - expect(authenticatedUser?.email).toBe(testUser.email); - }); - - it('should handle logout flow', async () => { - // Ensure role exists (it should from beforeAll, but double-check) - let roleId = testRoleId; - const roleExists = await prisma.role.findUnique({ where: { roleId } }); - let newRole; - if (!roleExists) { - newRole = await prisma.role.create({ - data: { roleType: 'MEMBER' }, - }); - roleId = newRole.roleId; - } - - // Create a user first - const user = await prisma.user.create({ - data: { - supabaseAuthId: 'test-logout-supabase-id', - nuid: '001234666', - email: 'logouttest@example.com', - firstName: 'Logout', - lastName: 'Test', - roleId: roleId, - password: null, - }, - }); - - // Mock sign out - mockSignOut.mockResolvedValueOnce({ - error: null, - }); - - // Simulate logout - const logoutResult = await mockSupabaseClient.auth.signOut(); - expect(logoutResult.error).toBeNull(); - - // Verify session is cleared (simulate AuthContext behavior) - mockGetSession.mockResolvedValueOnce({ - data: { session: null }, - error: null, - }); - - const sessionAfterLogout = await mockSupabaseClient.auth.getSession(); - expect(sessionAfterLogout.data.session).toBeNull(); - - // Cleanup - await prisma.user.delete({ where: { userId: user.userId } }); - if (newRole) { - await UsersService.deleteRole(newRole.roleId); - } - }); - }); - - describe('Auth Flow Error Handling', () => { - it('should handle signup failure gracefully', async () => { - mockSignUp.mockResolvedValueOnce({ - data: { user: null }, - error: { message: 'Email already exists' }, - }); - - const signupModule = await import('../../app/api/auth/signup/route'); - const signupPOST = signupModule.POST; - const signupReq = new Request('http://localhost/api/auth/signup', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'existing@example.com', - password: 'password123', - firstName: 'Test', - lastName: 'User', - nuid: '001234555', - }), - }); - - const signupResponse = await signupPOST(signupReq); - const signupData = await signupResponse.json(); - - expect(signupResponse.status).toBe(400); - expect(signupData.error).toBe('Email already exists'); - }); - - it('should handle login failure gracefully', async () => { - mockSignIn.mockResolvedValueOnce({ - data: { user: null, session: null }, - error: { message: 'Invalid email or password' }, - }); - - const loginResult = await mockSupabaseClient.auth.signInWithPassword({ - email: 'wrong@example.com', - password: 'wrongpassword', - }); - - expect(loginResult.error).toBeDefined(); - expect(loginResult.error.message).toBe('Invalid email or password'); - expect(loginResult.data.user).toBeNull(); - }); - - it('should handle session expiration', async () => { - mockGetSession.mockResolvedValueOnce({ - data: { session: null }, - error: { message: 'Session expired' }, - }); - - const sessionResult = await mockSupabaseClient.auth.getSession(); - expect(sessionResult.data.session).toBeNull(); - expect(sessionResult.error).toBeDefined(); - }); - }); - - describe('Auth Flow Edge Cases', () => { - it('should handle user profile not found after successful Supabase auth', async () => { - // Create session with Supabase ID that doesn't exist in our database - mockGetSession.mockResolvedValueOnce({ - data: { - session: { - user: { - id: 'non-existent-supabase-id', - email: 'notindb@example.com', - }, - }, - }, - error: null, - }); - - const apiAuthModule = await import('../../utils/api-auth'); - const getAuthenticatedUser = apiAuthModule.getAuthenticatedUser; - const user = await getAuthenticatedUser(); - - expect(user).toBeNull(); - }); - - it('should handle multiple concurrent signups', async () => { - // Ensure role exists for concurrent signups - let roleId = testRoleId; - const roleExists = await prisma.role.findUnique({ where: { roleId } }); - let newRole; - if (!roleExists) { - newRole = await prisma.role.create({ - data: { roleType: 'MEMBER' }, - }); - roleId = newRole.roleId; - } - (UsersService.getRoleIdByRoleType as jest.Mock).mockResolvedValue(roleId); - - const users = [ - { - email: 'concurrent1@example.com', - nuid: '001234111', - supabaseId: 'concurrent-1', - }, - { - email: 'concurrent2@example.com', - nuid: '001234222', - supabaseId: 'concurrent-2', - }, - { - email: 'concurrent3@example.com', - nuid: '001234333', - supabaseId: 'concurrent-3', - }, - ]; - - const signupModule = await import('../../app/api/auth/signup/route'); - const signupPOST = signupModule.POST; - - const signupPromises = users.map((user, index) => { - mockSignUp.mockResolvedValueOnce({ - data: { - user: { - id: user.supabaseId, - email: user.email, - }, - }, - error: null, - }); - - return signupPOST( - new Request('http://localhost/api/auth/signup', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: user.email, - password: 'password123', - firstName: 'Concurrent', - lastName: `User${index + 1}`, - nuid: user.nuid, - }), - }), - ); - }); - - const responses = await Promise.all(signupPromises); - - for (const response of responses) { - expect(response.status).toBe(201); - const data = await response.json(); - expect(data.user).toBeDefined(); - } - - // Cleanup - await prisma.user.deleteMany({ - where: { - supabaseAuthId: { in: users.map((u) => u.supabaseId) }, - }, - }); - if (newRole) { - await UsersService.deleteRole(newRole.roleId); - } - }); - }); -}); +import { prisma } from '@/lib/prisma'; +import { createServerSupabaseClient } from '@/lib/supabase-server'; +import { UsersService } from '@/users/users.service'; + +jest.setTimeout(20000); + +// Mock Supabase for integration tests +jest.mock('@/lib/supabase-server', () => ({ + createServerSupabaseClient: jest.fn() +})); + +// Mock UsersService for signup +jest.mock('@/users/users.service', () => { + const actual = jest.requireActual('@/users/users.service'); + return { + ...actual, + UsersService: { + ...actual.UsersService, + getRoleIdByRoleType: jest.fn() + } + }; +}); + +describe('Auth Flow Integration Tests', () => { + let testRoleId: string; + let mockSupabaseClient: any; + const mockSignUp = jest.fn(); + const mockSignIn = jest.fn(); + const mockGetSession = jest.fn(); + const mockSignOut = jest.fn(); + + beforeAll(async () => { + // Create test role + const role = await prisma.role.create({ + data: { roleType: 'MEMBER' } + }); + testRoleId = role.roleId; + + // Setup Supabase mocks + mockSupabaseClient = { + auth: { + signUp: mockSignUp, + signInWithPassword: mockSignIn, + getSession: mockGetSession, + signOut: mockSignOut + } + }; + + (createServerSupabaseClient as jest.Mock).mockResolvedValue( + mockSupabaseClient + ); + (UsersService.getRoleIdByRoleType as jest.Mock).mockResolvedValue( + testRoleId + ); + }); + + afterAll(async () => { + await UsersService.deleteRole(testRoleId); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Complete Auth Flow: Signup -> Login -> Session', () => { + const testUser = { + email: 'flowtest@example.com', + password: 'password123', + firstName: 'Flow', + lastName: 'Test', + nuid: '001234777' + }; + + const supabaseAuthId = 'test-flow-supabase-id-123'; + + it('should complete full auth flow: signup, login, and session check', async () => { + // Step 1: Signup + mockSignUp.mockResolvedValueOnce({ + data: { + user: { + id: supabaseAuthId, + email: testUser.email + } + }, + error: null + }); + + const signupModule = await import('../../app/api/auth/signup/route'); + const signupPOST = signupModule.POST; + const signupReq = new Request('http://localhost/api/auth/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(testUser) + }); + + const signupResponse = await signupPOST(signupReq); + const signupData = await signupResponse.json(); + + expect(signupResponse.status).toBe(201); + expect(signupData.user).toBeDefined(); + expect(signupData.user.supabaseAuthId).toBe(supabaseAuthId); + expect(signupData.user.email).toBe(testUser.email); + + // Verify user was created in database + const createdUser = await prisma.user.findUnique({ + where: { supabaseAuthId }, + }); + expect(createdUser).toBeDefined(); + expect(createdUser?.email).toBe(testUser.email); + + // Step 2: Login (simulate AuthContext.login behavior) + mockSignIn.mockResolvedValueOnce({ + data: { + user: { + id: supabaseAuthId, + email: testUser.email + }, + session: { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token' + } + }, + error: null + }); + + // Simulate login by calling Supabase signInWithPassword + const loginResult = await mockSupabaseClient.auth.signInWithPassword({ + email: testUser.email, + password: testUser.password + }); + + expect(loginResult.data.user).toBeDefined(); + expect(loginResult.data.user.id).toBe(supabaseAuthId); + expect(loginResult.error).toBeNull(); + + // Step 3: Fetch user profile by supabaseAuthId (simulate AuthContext.loadUserProfile) + const getUserModule = await import( + '../../app/api/users/by-supabase-id/[supabaseAuthId]/route' + ); + const getUserGET = getUserModule.GET; + const getUserReq = new Request( + `http://localhost/api/users/by-supabase-id/${supabaseAuthId}` + ); + + const getUserResponse = await getUserGET(getUserReq, { + params: Promise.resolve({ supabaseAuthId }) + }); + const userProfile = await getUserResponse.json(); + + expect(getUserResponse.status).toBe(200); + expect(userProfile.supabaseAuthId).toBe(supabaseAuthId); + expect(userProfile.email).toBe(testUser.email); + expect(userProfile.roleType).toBe('MEMBER'); + + // Step 4: Session check (simulate middleware/AuthContext session check) + mockGetSession.mockResolvedValueOnce({ + data: { + session: { + user: { + id: supabaseAuthId, + email: testUser.email + }, + access_token: 'mock-access-token' + } + }, + error: null + }); + + const sessionResult = await mockSupabaseClient.auth.getSession(); + expect(sessionResult.data.session).toBeDefined(); + expect(sessionResult.data.session.user.id).toBe(supabaseAuthId); + + // Step 5: Verify authenticated user can be retrieved + const apiAuthModule = await import('../../utils/api-auth'); + const getAuthenticatedUser = apiAuthModule.getAuthenticatedUser; + + // Mock getSession for getAuthenticatedUser + mockGetSession.mockResolvedValueOnce({ + data: { + session: { + user: { + id: supabaseAuthId, + email: testUser.email + } + } + }, + error: null + }); + + const authenticatedUser = await getAuthenticatedUser(); + expect(authenticatedUser).toBeDefined(); + expect(authenticatedUser?.supabaseAuthId).toBe(supabaseAuthId); + expect(authenticatedUser?.email).toBe(testUser.email); + }); + + it('should handle logout flow', async () => { + // Ensure role exists (it should from beforeAll, but double-check) + let roleId = testRoleId; + const roleExists = await prisma.role.findUnique({ where: { roleId } }); + let newRole; + if (!roleExists) { + newRole = await prisma.role.create({ + data: { roleType: 'MEMBER' } + }); + roleId = newRole.roleId; + } + + // Create a user first + const user = await prisma.user.create({ + data: { + supabaseAuthId: 'test-logout-supabase-id', + nuid: '001234666', + email: 'logouttest@example.com', + firstName: 'Logout', + lastName: 'Test', + roleId: roleId, + password: null + } + }); + + // Mock sign out + mockSignOut.mockResolvedValueOnce({ + error: null + }); + + // Simulate logout + const logoutResult = await mockSupabaseClient.auth.signOut(); + expect(logoutResult.error).toBeNull(); + + // Verify session is cleared (simulate AuthContext behavior) + mockGetSession.mockResolvedValueOnce({ + data: { session: null }, + error: null + }); + + const sessionAfterLogout = await mockSupabaseClient.auth.getSession(); + expect(sessionAfterLogout.data.session).toBeNull(); + + // Cleanup + await prisma.user.delete({ where: { userId: user.userId } }); + if (newRole) { + await UsersService.deleteRole(newRole.roleId); + } + }); + }); + + describe('Auth Flow Error Handling', () => { + it('should handle signup failure gracefully', async () => { + mockSignUp.mockResolvedValueOnce({ + data: { user: null }, + error: { message: 'Email already exists' } + }); + + const signupModule = await import('../../app/api/auth/signup/route'); + const signupPOST = signupModule.POST; + const signupReq = new Request('http://localhost/api/auth/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'existing@example.com', + password: 'password123', + firstName: 'Test', + lastName: 'User', + nuid: '001234555' + }) + }); + + const signupResponse = await signupPOST(signupReq); + const signupData = await signupResponse.json(); + + expect(signupResponse.status).toBe(400); + expect(signupData.error).toBe('Email already exists'); + }); + + it('should handle login failure gracefully', async () => { + mockSignIn.mockResolvedValueOnce({ + data: { user: null, session: null }, + error: { message: 'Invalid email or password' } + }); + + const loginResult = await mockSupabaseClient.auth.signInWithPassword({ + email: 'wrong@example.com', + password: 'wrongpassword' + }); + + expect(loginResult.error).toBeDefined(); + expect(loginResult.error.message).toBe('Invalid email or password'); + expect(loginResult.data.user).toBeNull(); + }); + + it('should handle session expiration', async () => { + mockGetSession.mockResolvedValueOnce({ + data: { session: null }, + error: { message: 'Session expired' } + }); + + const sessionResult = await mockSupabaseClient.auth.getSession(); + expect(sessionResult.data.session).toBeNull(); + expect(sessionResult.error).toBeDefined(); + }); + }); + + describe('Auth Flow Edge Cases', () => { + it('should handle user profile not found after successful Supabase auth', async () => { + // Create session with Supabase ID that doesn't exist in our database + mockGetSession.mockResolvedValueOnce({ + data: { + session: { + user: { + id: 'non-existent-supabase-id', + email: 'notindb@example.com' + } + } + }, + error: null + }); + + const apiAuthModule = await import('../../utils/api-auth'); + const getAuthenticatedUser = apiAuthModule.getAuthenticatedUser; + const user = await getAuthenticatedUser(); + + expect(user).toBeNull(); + }); + + it('should handle multiple concurrent signups', async () => { + // Ensure role exists for concurrent signups + let roleId = testRoleId; + const roleExists = await prisma.role.findUnique({ where: { roleId } }); + let newRole; + if (!roleExists) { + newRole = await prisma.role.create({ + data: { roleType: 'MEMBER' } + }); + roleId = newRole.roleId; + } + (UsersService.getRoleIdByRoleType as jest.Mock).mockResolvedValue(roleId); + + const users = [ + { + email: 'concurrent1@example.com', + nuid: '001234111', + supabaseId: 'concurrent-1' + }, + { + email: 'concurrent2@example.com', + nuid: '001234222', + supabaseId: 'concurrent-2' + }, + { + email: 'concurrent3@example.com', + nuid: '001234333', + supabaseId: 'concurrent-3' + } + ]; + + const signupModule = await import('../../app/api/auth/signup/route'); + const signupPOST = signupModule.POST; + + const signupPromises = users.map((user, index) => { + mockSignUp.mockResolvedValueOnce({ + data: { + user: { + id: user.supabaseId, + email: user.email + } + }, + error: null + }); + + return signupPOST( + new Request('http://localhost/api/auth/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: user.email, + password: 'password123', + firstName: 'Concurrent', + lastName: `User${index + 1}`, + nuid: user.nuid + }) + }) + ); + }); + + const responses = await Promise.all(signupPromises); + + for (const response of responses) { + expect(response.status).toBe(201); + const data = await response.json(); + expect(data.user).toBeDefined(); + } + + // Cleanup + await prisma.user.deleteMany({ + where: { + supabaseAuthId: { in: users.map(u => u.supabaseId) } + } + }); + if (newRole) { + await UsersService.deleteRole(newRole.roleId); + } + }); + }); +}); diff --git a/attendance-manager/src/components/attendance/AttendanceHistory.tsx b/attendance-manager/src/components/attendance/AttendanceHistory.tsx index c4418410..5b1935d1 100644 --- a/attendance-manager/src/components/attendance/AttendanceHistory.tsx +++ b/attendance-manager/src/components/attendance/AttendanceHistory.tsx @@ -70,7 +70,9 @@ const AttendanceHistory: React.FC = ({ -
{record.notes}
+
+ {record.notes} +
diff --git a/attendance-manager/src/components/attendance/AttendancePage.tsx b/attendance-manager/src/components/attendance/AttendancePage.tsx index 6a8bee87..2f2cc19b 100644 --- a/attendance-manager/src/components/attendance/AttendancePage.tsx +++ b/attendance-manager/src/components/attendance/AttendancePage.tsx @@ -20,6 +20,7 @@ import { meetingAPI } from '@/utils/attendance_utils'; import AttendancePageRequestsModal from './AttendancePageRequestsModal'; import DeleteUserModal from './DeleteUserModal'; import { ClipboardList, NotebookPen } from 'lucide-react'; +import { checkCanManageAttendance } from '@/utils/permissions'; const AttendancePage: React.FC = () => { const { user } = useAuth(); @@ -72,7 +73,7 @@ const AttendancePage: React.FC = () => { const [declinedRequestIds, setDeclinedRequestIds] = useState([]); // Check if user is admin (EBOARD) - const isAdmin = user?.role === 'EBOARD'; + const canManageAttendance = checkCanManageAttendance(user?.role); useEffect(() => { const loadMeetings = async () => { const allMeetings = await meetingAPI.getAllMeetings(); @@ -333,8 +334,8 @@ const AttendancePage: React.FC = () => { setShowEditAttendanceModal(true); }; - const eboardMembers = users.filter((m) => m.role.roleType === 'EBOARD'); - const regularMembers = users.filter((m) => m.role.roleType === 'MEMBER'); + const eboardMembers = users.filter((m) => m.roleType === 'EBOARD'); + const regularMembers = users.filter((m) => m.roleType === 'MEMBER'); return (
@@ -348,7 +349,7 @@ const AttendancePage: React.FC = () => { Manage SGA members and track attendance history

- {isAdmin && ( + {canManageAttendance && (
- {isAdmin && ( + {canViewMemberStats && ( <>
Total Members diff --git a/attendance-manager/src/components/meetings/CreateMeetingModal.tsx b/attendance-manager/src/components/meetings/CreateMeetingModal.tsx index bc4a1dc1..795bbccc 100644 --- a/attendance-manager/src/components/meetings/CreateMeetingModal.tsx +++ b/attendance-manager/src/components/meetings/CreateMeetingModal.tsx @@ -251,12 +251,12 @@ const CreateMeetingModal: React.FC = ({

{member.email}

- {member.role.roleType === 'EBOARD' ? 'Eboard' : 'Member'} + {member.roleType === 'EBOARD' ? 'Eboard' : 'Member'}
diff --git a/attendance-manager/src/components/meetings/MeetingHistoryPanel.tsx b/attendance-manager/src/components/meetings/MeetingHistoryPanel.tsx index 1021fdd9..6d45b39f 100644 --- a/attendance-manager/src/components/meetings/MeetingHistoryPanel.tsx +++ b/attendance-manager/src/components/meetings/MeetingHistoryPanel.tsx @@ -1,7 +1,8 @@ +import React from 'react'; import { MeetingApiData, MeetingType } from '@/types'; import { useAuth } from '@/contexts/AuthContext'; +import { checkCanEditMeetings } from '@/utils/permissions'; import { Calendar } from 'lucide-react'; -import React from 'react'; interface MeetingHistoryPanelProps { setActiveTab: (option: 'past' | 'upcoming') => void; @@ -35,7 +36,7 @@ const MeetingHistoryPanel: React.FC = ({ return type; }; const { user } = useAuth(); - const isEboard = user?.role === 'EBOARD'; + const canEditMeetings = checkCanEditMeetings(user?.role); return (
@@ -128,7 +129,7 @@ const MeetingHistoryPanel: React.FC = ({ # of Members - {isEboard && ( + {canEditMeetings && ( Actions @@ -136,7 +137,7 @@ const MeetingHistoryPanel: React.FC = ({ - {visibleMeetings.map((meeting) => ( + {visibleMeetings.map((meeting) => ( = ({
- {isEboard && ( + {canEditMeetings && (
{/* Create Meeting Button - Only for Admins */} - {isAdmin && ( + {canManageMeetings && (