From a8962247fb6a78d7a128fdd24a75f118c8295014 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Tue, 2 Dec 2025 00:52:48 +0000 Subject: [PATCH] Implement permission utilities (task 9) - Created lib/permissions.ts with four utility functions: - canAccessList: Check if user can view a list - canEditList: Check if user can edit a list or its items - canDeleteList: Check if user can delete a list (owner only) - getPermission: Get user's permission level for a list - Added comprehensive unit tests in lib/__tests__/permissions.test.ts - All 24 tests passing - Validates Requirements 4.3, 6.2, 6.3 --- .kiro/specs/user-auth-and-sharing/tasks.md | 2 +- lib/__tests__/permissions.test.ts | 268 +++++++++++++++++++++ lib/permissions.ts | 164 +++++++++++++ 3 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 lib/__tests__/permissions.test.ts create mode 100644 lib/permissions.ts diff --git a/.kiro/specs/user-auth-and-sharing/tasks.md b/.kiro/specs/user-auth-and-sharing/tasks.md index 83a4925..49d8b43 100644 --- a/.kiro/specs/user-auth-and-sharing/tasks.md +++ b/.kiro/specs/user-auth-and-sharing/tasks.md @@ -119,7 +119,7 @@ - **Property 16: Account deletion cascades to lists** - **Validates: Requirements 4.5** -- [ ] 9. Implement permission utilities +- [x] 9. Implement permission utilities - Create canAccessList function - Create canEditList function - Create canDeleteList function diff --git a/lib/__tests__/permissions.test.ts b/lib/__tests__/permissions.test.ts new file mode 100644 index 0000000..ebb05f6 --- /dev/null +++ b/lib/__tests__/permissions.test.ts @@ -0,0 +1,268 @@ +/** + * Tests for permission utility functions + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { prisma } from '../db' +import { + canAccessList, + canEditList, + canDeleteList, + getPermission, +} from '../permissions' +import bcrypt from 'bcryptjs' + +describe('Permission Utilities', () => { + let testUser1Id: string + let testUser2Id: string + let testListId: string + + beforeEach(async () => { + // Create test users + const passwordHash = await bcrypt.hash('password123', 10) + + const user1 = await prisma.user.create({ + data: { + email: `test-perm-user1-${Date.now()}@example.com`, + passwordHash, + name: 'Test User 1', + }, + }) + testUser1Id = user1.id + + const user2 = await prisma.user.create({ + data: { + email: `test-perm-user2-${Date.now()}@example.com`, + passwordHash, + name: 'Test User 2', + }, + }) + testUser2Id = user2.id + + // Create a test list owned by user1 + const list = await prisma.list.create({ + data: { + title: 'Test List', + color: '#3B82F6', + ownerId: testUser1Id, + }, + }) + testListId = list.id + }) + + afterEach(async () => { + // Clean up test data + await prisma.list.deleteMany({ + where: { + id: testListId, + }, + }) + await prisma.user.deleteMany({ + where: { + id: { + in: [testUser1Id, testUser2Id], + }, + }, + }) + }) + + describe('canAccessList', () => { + it('should return true for list owner', async () => { + const result = await canAccessList(testUser1Id, testListId) + expect(result).toBe(true) + }) + + it('should return false for non-owner without collaboration', async () => { + const result = await canAccessList(testUser2Id, testListId) + expect(result).toBe(false) + }) + + it('should return true for editor collaborator', async () => { + // Add user2 as editor + await prisma.collaborator.create({ + data: { + userId: testUser2Id, + listId: testListId, + permission: 'editor', + }, + }) + + const result = await canAccessList(testUser2Id, testListId) + expect(result).toBe(true) + }) + + it('should return true for viewer collaborator', async () => { + // Add user2 as viewer + await prisma.collaborator.create({ + data: { + userId: testUser2Id, + listId: testListId, + permission: 'viewer', + }, + }) + + const result = await canAccessList(testUser2Id, testListId) + expect(result).toBe(true) + }) + + it('should return false for invalid user ID', async () => { + const result = await canAccessList('', testListId) + expect(result).toBe(false) + }) + + it('should return false for invalid list ID', async () => { + const result = await canAccessList(testUser1Id, '') + expect(result).toBe(false) + }) + }) + + describe('canEditList', () => { + it('should return true for list owner', async () => { + const result = await canEditList(testUser1Id, testListId) + expect(result).toBe(true) + }) + + it('should return false for non-owner without collaboration', async () => { + const result = await canEditList(testUser2Id, testListId) + expect(result).toBe(false) + }) + + it('should return true for editor collaborator', async () => { + // Add user2 as editor + await prisma.collaborator.create({ + data: { + userId: testUser2Id, + listId: testListId, + permission: 'editor', + }, + }) + + const result = await canEditList(testUser2Id, testListId) + expect(result).toBe(true) + }) + + it('should return false for viewer collaborator', async () => { + // Add user2 as viewer + await prisma.collaborator.create({ + data: { + userId: testUser2Id, + listId: testListId, + permission: 'viewer', + }, + }) + + const result = await canEditList(testUser2Id, testListId) + expect(result).toBe(false) + }) + + it('should return false for invalid user ID', async () => { + const result = await canEditList('', testListId) + expect(result).toBe(false) + }) + + it('should return false for invalid list ID', async () => { + const result = await canEditList(testUser1Id, '') + expect(result).toBe(false) + }) + }) + + describe('canDeleteList', () => { + it('should return true for list owner', async () => { + const result = await canDeleteList(testUser1Id, testListId) + expect(result).toBe(true) + }) + + it('should return false for non-owner', async () => { + const result = await canDeleteList(testUser2Id, testListId) + expect(result).toBe(false) + }) + + it('should return false for editor collaborator', async () => { + // Add user2 as editor + await prisma.collaborator.create({ + data: { + userId: testUser2Id, + listId: testListId, + permission: 'editor', + }, + }) + + const result = await canDeleteList(testUser2Id, testListId) + expect(result).toBe(false) + }) + + it('should return false for viewer collaborator', async () => { + // Add user2 as viewer + await prisma.collaborator.create({ + data: { + userId: testUser2Id, + listId: testListId, + permission: 'viewer', + }, + }) + + const result = await canDeleteList(testUser2Id, testListId) + expect(result).toBe(false) + }) + + it('should return false for invalid user ID', async () => { + const result = await canDeleteList('', testListId) + expect(result).toBe(false) + }) + + it('should return false for invalid list ID', async () => { + const result = await canDeleteList(testUser1Id, '') + expect(result).toBe(false) + }) + }) + + describe('getPermission', () => { + it('should return "owner" for list owner', async () => { + const result = await getPermission(testUser1Id, testListId) + expect(result).toBe('owner') + }) + + it('should return null for non-owner without collaboration', async () => { + const result = await getPermission(testUser2Id, testListId) + expect(result).toBeNull() + }) + + it('should return "editor" for editor collaborator', async () => { + // Add user2 as editor + await prisma.collaborator.create({ + data: { + userId: testUser2Id, + listId: testListId, + permission: 'editor', + }, + }) + + const result = await getPermission(testUser2Id, testListId) + expect(result).toBe('editor') + }) + + it('should return "viewer" for viewer collaborator', async () => { + // Add user2 as viewer + await prisma.collaborator.create({ + data: { + userId: testUser2Id, + listId: testListId, + permission: 'viewer', + }, + }) + + const result = await getPermission(testUser2Id, testListId) + expect(result).toBe('viewer') + }) + + it('should return null for invalid user ID', async () => { + const result = await getPermission('', testListId) + expect(result).toBeNull() + }) + + it('should return null for invalid list ID', async () => { + const result = await getPermission(testUser1Id, '') + expect(result).toBeNull() + }) + }) +}) diff --git a/lib/permissions.ts b/lib/permissions.ts new file mode 100644 index 0000000..41fa695 --- /dev/null +++ b/lib/permissions.ts @@ -0,0 +1,164 @@ +'use server' + +import { prisma } from './db' + +export type Permission = 'owner' | 'editor' | 'viewer' + +/** + * Check if a user can access a list (view it) + * Users can access a list if they are the owner or a collaborator + * + * @param userId - The ID of the user + * @param listId - The ID of the list + * @returns true if the user can access the list, false otherwise + */ +export async function canAccessList( + userId: string, + listId: string +): Promise { + try { + if (!userId || !listId) { + return false + } + + const list = await prisma.list.findFirst({ + where: { + id: listId, + OR: [ + { ownerId: userId }, + { + collaborators: { + some: { + userId: userId, + }, + }, + }, + ], + }, + }) + + return list !== null + } catch (error) { + console.error('Error checking list access:', error) + return false + } +} + +/** + * Check if a user can edit a list or its items + * Users can edit if they are the owner or have editor permission + * + * @param userId - The ID of the user + * @param listId - The ID of the list + * @returns true if the user can edit the list, false otherwise + */ +export async function canEditList( + userId: string, + listId: string +): Promise { + try { + if (!userId || !listId) { + return false + } + + const list = await prisma.list.findFirst({ + where: { + id: listId, + OR: [ + { ownerId: userId }, + { + collaborators: { + some: { + userId: userId, + permission: 'editor', + }, + }, + }, + ], + }, + }) + + return list !== null + } catch (error) { + console.error('Error checking list edit permission:', error) + return false + } +} + +/** + * Check if a user can delete a list + * Only the owner can delete a list + * + * @param userId - The ID of the user + * @param listId - The ID of the list + * @returns true if the user can delete the list, false otherwise + */ +export async function canDeleteList( + userId: string, + listId: string +): Promise { + try { + if (!userId || !listId) { + return false + } + + const list = await prisma.list.findFirst({ + where: { + id: listId, + ownerId: userId, + }, + }) + + return list !== null + } catch (error) { + console.error('Error checking list delete permission:', error) + return false + } +} + +/** + * Get the permission level a user has for a list + * + * @param userId - The ID of the user + * @param listId - The ID of the list + * @returns The permission level ('owner', 'editor', 'viewer') or null if no access + */ +export async function getPermission( + userId: string, + listId: string +): Promise { + try { + if (!userId || !listId) { + return null + } + + // Check if user is the owner + const ownedList = await prisma.list.findFirst({ + where: { + id: listId, + ownerId: userId, + }, + }) + + if (ownedList) { + return 'owner' + } + + // Check if user is a collaborator + const collaborator = await prisma.collaborator.findFirst({ + where: { + userId: userId, + listId: listId, + }, + }) + + if (collaborator) { + return collaborator.permission as Permission + } + + return null + } catch (error) { + console.error('Error getting user permission:', error) + return null + } +}