diff --git a/apps/web/src/actions/__tests__/auth.test.ts b/apps/web/src/actions/__tests__/auth.test.ts new file mode 100644 index 0000000000..70745fa0e8 --- /dev/null +++ b/apps/web/src/actions/__tests__/auth.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { authorizeAnalytics } from '../auth'; + +// Mock Clerk auth +vi.mock('@clerk/nextjs/server', () => ({ + auth: vi.fn(), +})); + +describe('authorizeAnalytics', () => { + let mockAuth: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + mockAuth = vi.mocked((await import('@clerk/nextjs/server')).auth); + }); + + describe('Personal account access', () => { + it('should allow personal user to access their own data', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: null, + orgRole: null, + }); + + const result = await authorizeAnalytics({ + requestedOrgId: null, + requestedUserId: 'user-123', + }); + + expect(result).toEqual({ + authOrgId: null, + authUserId: 'user-123', + orgRole: null, + isAdmin: false, + }); + }); + + it('should allow personal user to access data without specifying userId', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: null, + orgRole: null, + }); + + const result = await authorizeAnalytics({ + requestedOrgId: null, + requestedUserId: null, + }); + + expect(result).toEqual({ + authOrgId: null, + authUserId: 'user-123', + orgRole: null, + isAdmin: false, + }); + }); + + it('should throw error when personal user tries to access other user data', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: null, + orgRole: null, + }); + + await expect( + authorizeAnalytics({ + requestedOrgId: null, + requestedUserId: 'other-user-456', + }), + ).rejects.toThrow( + 'Unauthorized: Personal users can only access their own data', + ); + }); + + it('should throw error when no user is authenticated', async () => { + mockAuth.mockResolvedValue({ + userId: null, + orgId: null, + orgRole: null, + }); + + await expect( + authorizeAnalytics({ + requestedOrgId: null, + requestedUserId: null, + }), + ).rejects.toThrow('Unauthorized: User required'); + }); + }); + + describe('Organization account access', () => { + it('should allow org member to access org data', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'org-456', + orgRole: 'org:member', + }); + + const result = await authorizeAnalytics({ + requestedOrgId: 'org-456', + requestedUserId: null, + }); + + expect(result).toEqual({ + authOrgId: 'org-456', + authUserId: 'user-123', + orgRole: 'org:member', + isAdmin: false, + }); + }); + + it('should allow org admin to access org data', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'org-456', + orgRole: 'org:admin', + }); + + const result = await authorizeAnalytics({ + requestedOrgId: 'org-456', + requestedUserId: null, + }); + + expect(result).toEqual({ + authOrgId: 'org-456', + authUserId: 'user-123', + orgRole: 'org:admin', + isAdmin: true, + }); + }); + + it('should handle invalid org role by defaulting to member', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'org-456', + orgRole: 'invalid-role', + }); + + const result = await authorizeAnalytics({ + requestedOrgId: 'org-456', + requestedUserId: null, + }); + + expect(result).toEqual({ + authOrgId: 'org-456', + authUserId: 'user-123', + orgRole: 'org:member', + isAdmin: false, + }); + }); + + it('should throw error when user is not in the requested org', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'org-456', + orgRole: 'org:member', + }); + + await expect( + authorizeAnalytics({ + requestedOrgId: 'different-org-789', + requestedUserId: null, + }), + ).rejects.toThrow('Unauthorized: Invalid organization access'); + }); + + it('should throw error when user has no org but requests org data', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: null, + orgRole: null, + }); + + await expect( + authorizeAnalytics({ + requestedOrgId: 'org-456', + requestedUserId: null, + }), + ).rejects.toThrow('Unauthorized: Invalid organization access'); + }); + + it('should throw error when no user is authenticated for org access', async () => { + mockAuth.mockResolvedValue({ + userId: null, + orgId: 'org-456', + orgRole: 'org:member', + }); + + await expect( + authorizeAnalytics({ + requestedOrgId: 'org-456', + requestedUserId: null, + }), + ).rejects.toThrow('Unauthorized: Invalid organization access'); + }); + }); + + describe('Admin access requirements', () => { + it('should allow admin when requireAdmin is true', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'org-456', + orgRole: 'org:admin', + }); + + const result = await authorizeAnalytics({ + requestedOrgId: 'org-456', + requestedUserId: null, + requireAdmin: true, + }); + + expect(result).toEqual({ + authOrgId: 'org-456', + authUserId: 'user-123', + orgRole: 'org:admin', + isAdmin: true, + }); + }); + + it('should throw error when requireAdmin is true but user is not admin', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'org-456', + orgRole: 'org:member', + }); + + await expect( + authorizeAnalytics({ + requestedOrgId: 'org-456', + requestedUserId: null, + requireAdmin: true, + }), + ).rejects.toThrow('Unauthorized: Administrator access required'); + }); + + it('should not require admin for personal accounts even when requireAdmin is true', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: null, + orgRole: null, + }); + + const result = await authorizeAnalytics({ + requestedOrgId: null, + requestedUserId: 'user-123', + requireAdmin: true, + }); + + expect(result).toEqual({ + authOrgId: null, + authUserId: 'user-123', + orgRole: null, + isAdmin: false, + }); + }); + }); + + describe('Edge cases', () => { + it('should handle undefined orgRole', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'org-456', + orgRole: undefined, + }); + + const result = await authorizeAnalytics({ + requestedOrgId: 'org-456', + requestedUserId: null, + }); + + expect(result).toEqual({ + authOrgId: 'org-456', + authUserId: 'user-123', + orgRole: 'org:member', + isAdmin: false, + }); + }); + + it('should handle null orgRole', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'org-456', + orgRole: null, + }); + + const result = await authorizeAnalytics({ + requestedOrgId: 'org-456', + requestedUserId: null, + }); + + expect(result).toEqual({ + authOrgId: 'org-456', + authUserId: 'user-123', + orgRole: 'org:member', + isAdmin: false, + }); + }); + + it('should handle empty string orgRole', async () => { + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'org-456', + orgRole: '', + }); + + const result = await authorizeAnalytics({ + requestedOrgId: 'org-456', + requestedUserId: null, + }); + + expect(result).toEqual({ + authOrgId: 'org-456', + authUserId: 'user-123', + orgRole: 'org:member', + isAdmin: false, + }); + }); + }); +}); diff --git a/apps/web/src/actions/analytics/__tests__/events-auth.test.ts b/apps/web/src/actions/analytics/__tests__/events-auth.test.ts new file mode 100644 index 0000000000..c575f508ed --- /dev/null +++ b/apps/web/src/actions/analytics/__tests__/events-auth.test.ts @@ -0,0 +1,441 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getTaskById, getTasks } from '../events'; + +// Mock dependencies +vi.mock('@/lib/server', () => ({ + analytics: { + query: vi.fn(), + }, +})); + +vi.mock('@/actions/auth', () => ({ + authorizeAnalytics: vi.fn(), +})); + +vi.mock('@roo-code-cloud/db/server', () => ({ + getUsersById: vi.fn(), + db: { + select: vi.fn(), + }, + taskShares: {}, + eq: vi.fn(), +})); + +vi.mock('@/lib/task-sharing', () => ({ + isValidShareToken: vi.fn(), + isShareExpired: vi.fn(), +})); + +vi.mock('@clerk/nextjs/server', () => ({ + auth: vi.fn(), +})); + +vi.mock('@/types', () => ({ + TaskShareVisibility: { + PUBLIC: 'public', + ORGANIZATION: 'organization', + PRIVATE: 'private', + }, +})); + +describe('Share Token Authorization in Events', () => { + let mockAnalytics: unknown; + let mockAuthorizeAnalytics: unknown; + let mockGetUsersById: unknown; + let mockDb: unknown; + let mockIsValidShareToken: unknown; + let mockIsShareExpired: unknown; + let mockAuth: unknown; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { analytics } = await import('@/lib/server'); + mockAnalytics = vi.mocked(analytics); + + const { authorizeAnalytics } = await import('@/actions/auth'); + mockAuthorizeAnalytics = vi.mocked(authorizeAnalytics); + + const { getUsersById, db } = await import('@roo-code-cloud/db/server'); + mockGetUsersById = vi.mocked(getUsersById); + mockDb = vi.mocked(db); + + const { isValidShareToken, isShareExpired } = await import( + '@/lib/task-sharing' + ); + mockIsValidShareToken = vi.mocked(isValidShareToken); + mockIsShareExpired = vi.mocked(isShareExpired); + + const { auth } = await import('@clerk/nextjs/server'); + mockAuth = vi.mocked(auth); + + // Default mock implementations + mockGetUsersById.mockResolvedValue({ + 'user-123': { + id: 'user-123', + name: 'Test User', + email: 'test@example.com', + }, + }); + + mockAnalytics.query.mockResolvedValue({ + json: () => + Promise.resolve([ + { + taskId: 'task-123', + userId: 'user-123', + provider: 'openai', + model: 'gpt-4', + mode: 'code', + completed: true, + tokens: 1000, + cost: 0.02, + timestamp: 1640995200, + title: 'Test Task', + repositoryUrl: 'https://github.com/test/repo', + repositoryName: 'test-repo', + defaultBranch: 'main', + }, + ]), + }); + }); + + describe('authorizeShareToken function', () => { + it('should return null for invalid share token format', async () => { + mockIsValidShareToken.mockReturnValue(false); + + const result = await getTasks({ + shareToken: 'invalid-token', + taskId: 'task-123', + }); + + expect(result).toEqual({ tasks: [], hasMore: false }); + expect(mockIsValidShareToken).toHaveBeenCalledWith('invalid-token'); + }); + + it('should return null when share not found in database', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), // No share found + }), + }), + }); + + const result = await getTasks({ + shareToken: 'valid-token', + taskId: 'task-123', + }); + + expect(result).toEqual({ tasks: [], hasMore: false }); + }); + + it('should return null when share is expired', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(true); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'public', + expiresAt: new Date('2023-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + const result = await getTasks({ + shareToken: 'valid-token', + taskId: 'task-123', + }); + + expect(result).toEqual({ tasks: [], hasMore: false }); + expect(mockIsShareExpired).toHaveBeenCalledWith(mockShare.expiresAt); + }); + + it('should validate organization access for org-scoped shares', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'organization', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + // Mock auth to return different org + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'different-org-789', + }); + + const result = await getTasks({ + shareToken: 'valid-token', + taskId: 'task-123', + }); + + expect(result).toEqual({ tasks: [], hasMore: false }); + expect(mockAuth).toHaveBeenCalled(); + }); + + it('should allow access for valid organization share with matching org', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'organization', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + // Mock auth to return matching org + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'org-456', + }); + + const result = await getTasks({ + shareToken: 'valid-token', + taskId: 'task-123', + }); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].taskId).toBe('task-123'); + }); + + it('should allow access for valid public share', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'public', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + const result = await getTasks({ + shareToken: 'valid-token', + taskId: 'task-123', + }); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].taskId).toBe('task-123'); + // Should not call auth for public shares + expect(mockAuth).not.toHaveBeenCalled(); + }); + + it('should reject access when share token task does not match requested task', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'public', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + const result = await getTasks({ + shareToken: 'valid-token', + taskId: 'different-task-456', // Different task ID + }); + + expect(result).toEqual({ tasks: [], hasMore: false }); + }); + + it('should handle database errors gracefully', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockDb.select.mockImplementation(() => { + throw new Error('Database connection failed'); + }); + + const result = await getTasks({ + shareToken: 'valid-token', + taskId: 'task-123', + }); + + expect(result).toEqual({ tasks: [], hasMore: false }); + }); + + it('should handle auth errors gracefully for organization shares', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'organization', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + mockAuth.mockRejectedValue(new Error('Auth service unavailable')); + + const result = await getTasks({ + shareToken: 'valid-token', + taskId: 'task-123', + }); + + expect(result).toEqual({ tasks: [], hasMore: false }); + }); + }); + + describe('getTaskById with share token', () => { + it('should return task when valid share token is provided', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'public', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + const result = await getTaskById({ + taskId: 'task-123', + shareToken: 'valid-token', + }); + + expect(result).toBeTruthy(); + expect(result?.taskId).toBe('task-123'); + }); + + it('should return null when share token is invalid', async () => { + mockIsValidShareToken.mockReturnValue(false); + + const result = await getTaskById({ + taskId: 'task-123', + shareToken: 'invalid-token', + }); + + expect(result).toBeNull(); + }); + + it('should return null when task ID does not match share', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'public', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + const result = await getTaskById({ + taskId: 'different-task-456', + shareToken: 'valid-token', + }); + + expect(result).toBeNull(); + }); + }); + + describe('Normal authentication flow (without share token)', () => { + it('should use normal authorization when no share token provided', async () => { + mockAuthorizeAnalytics.mockResolvedValue({ + authUserId: 'user-123', + isAdmin: false, + }); + + const result = await getTasks({ + orgId: 'org-456', + userId: 'user-123', + }); + + expect(mockAuthorizeAnalytics).toHaveBeenCalledWith({ + requestedOrgId: 'org-456', + requestedUserId: 'user-123', + }); + expect(result.tasks).toHaveLength(1); + }); + + it('should not call share token validation when no share token provided', async () => { + mockAuthorizeAnalytics.mockResolvedValue({ + authUserId: 'user-123', + isAdmin: false, + }); + + await getTasks({ + orgId: 'org-456', + userId: 'user-123', + }); + + expect(mockIsValidShareToken).not.toHaveBeenCalled(); + expect(mockDb.select).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/actions/analytics/__tests__/messages-auth.test.ts b/apps/web/src/actions/analytics/__tests__/messages-auth.test.ts new file mode 100644 index 0000000000..7306fb9330 --- /dev/null +++ b/apps/web/src/actions/analytics/__tests__/messages-auth.test.ts @@ -0,0 +1,540 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getMessages } from '../messages'; + +// Mock dependencies +vi.mock('@/lib/server', () => ({ + analytics: { + query: vi.fn(), + }, +})); + +vi.mock('@/actions/auth', () => ({ + authorizeAnalytics: vi.fn(), +})); + +vi.mock('@roo-code-cloud/db/server', () => ({ + db: { + select: vi.fn(), + }, + taskShares: {}, + eq: vi.fn(), +})); + +vi.mock('@/lib/task-sharing', () => ({ + isValidShareToken: vi.fn(), + isShareExpired: vi.fn(), +})); + +vi.mock('@clerk/nextjs/server', () => ({ + auth: vi.fn(), +})); + +vi.mock('@/types', () => ({ + TaskShareVisibility: { + PUBLIC: 'public', + ORGANIZATION: 'organization', + PRIVATE: 'private', + }, +})); + +describe('Share Token Authorization in Messages', () => { + let mockAnalytics: unknown; + let mockAuthorizeAnalytics: unknown; + let mockDb: unknown; + let mockIsValidShareToken: unknown; + let mockIsShareExpired: unknown; + let mockAuth: unknown; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { analytics } = await import('@/lib/server'); + mockAnalytics = vi.mocked(analytics); + + const { authorizeAnalytics } = await import('@/actions/auth'); + mockAuthorizeAnalytics = vi.mocked(authorizeAnalytics); + + const { db } = await import('@roo-code-cloud/db/server'); + mockDb = vi.mocked(db); + + const { isValidShareToken, isShareExpired } = await import( + '@/lib/task-sharing' + ); + mockIsValidShareToken = vi.mocked(isValidShareToken); + mockIsShareExpired = vi.mocked(isShareExpired); + + const { auth } = await import('@clerk/nextjs/server'); + mockAuth = vi.mocked(auth); + + // Default mock implementations + mockAnalytics.query.mockResolvedValue({ + json: () => + Promise.resolve([ + { + id: 'msg-1', + orgId: 'org-456', + userId: 'user-123', + taskId: 'task-123', + mode: 'code', + ts: 1640995200000, + type: 'ask', + ask: 'What should I do?', + say: null, + text: 'What should I do?', + reasoning: null, + partial: false, + timestamp: 1640995200, + }, + { + id: 'msg-2', + orgId: 'org-456', + userId: 'user-123', + taskId: 'task-123', + mode: 'code', + ts: 1640995260000, + type: 'say', + ask: null, + say: 'I can help you with that.', + text: 'I can help you with that.', + reasoning: 'User needs assistance', + partial: false, + timestamp: 1640995260, + }, + ]), + }); + }); + + describe('authorizeMessageShareToken function', () => { + it('should return null for invalid share token format', async () => { + mockIsValidShareToken.mockReturnValue(false); + + const result = await getMessages( + 'task-123', + 'org-456', + 'user-123', + 'invalid-token', + ); + + expect(result).toEqual([]); + expect(mockIsValidShareToken).toHaveBeenCalledWith('invalid-token'); + }); + + it('should return null when share not found in database', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), // No share found + }), + }), + }); + + const result = await getMessages( + 'task-123', + 'org-456', + 'user-123', + 'valid-token', + ); + + expect(result).toEqual([]); + }); + + it('should return null when share is expired', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(true); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'public', + expiresAt: new Date('2023-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + const result = await getMessages( + 'task-123', + 'org-456', + 'user-123', + 'valid-token', + ); + + expect(result).toEqual([]); + expect(mockIsShareExpired).toHaveBeenCalledWith(mockShare.expiresAt); + }); + + it('should validate organization access for org-scoped shares', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'organization', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + // Mock auth to return different org + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'different-org-789', + }); + + const result = await getMessages( + 'task-123', + 'org-456', + 'user-123', + 'valid-token', + ); + + expect(result).toEqual([]); + expect(mockAuth).toHaveBeenCalled(); + }); + + it('should allow access for valid organization share with matching org', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'organization', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + // Mock auth to return matching org + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: 'org-456', + }); + + const result = await getMessages( + 'task-123', + 'org-456', + 'user-123', + 'valid-token', + ); + + expect(result).toHaveLength(2); + expect(result[0].taskId).toBe('task-123'); + expect(result[1].taskId).toBe('task-123'); + }); + + it('should allow access for valid public share', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'public', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + const result = await getMessages( + 'task-123', + 'org-456', + 'user-123', + 'valid-token', + ); + + expect(result).toHaveLength(2); + expect(result[0].taskId).toBe('task-123'); + // Should not call auth for public shares + expect(mockAuth).not.toHaveBeenCalled(); + }); + + it('should reject access when share token task does not match requested task', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'public', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + const result = await getMessages( + 'different-task-456', + 'org-456', + 'user-123', + 'valid-token', + ); + + expect(result).toEqual([]); + }); + + it('should handle database errors gracefully', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockDb.select.mockImplementation(() => { + throw new Error('Database connection failed'); + }); + + const result = await getMessages( + 'task-123', + 'org-456', + 'user-123', + 'valid-token', + ); + + expect(result).toEqual([]); + }); + + it('should handle auth errors gracefully for organization shares', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'organization', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + mockAuth.mockRejectedValue(new Error('Auth service unavailable')); + + const result = await getMessages( + 'task-123', + 'org-456', + 'user-123', + 'valid-token', + ); + + expect(result).toEqual([]); + }); + + it('should handle missing user in auth response for organization shares', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'organization', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + // Mock auth to return no user + mockAuth.mockResolvedValue({ + userId: null, + orgId: 'org-456', + }); + + const result = await getMessages( + 'task-123', + 'org-456', + 'user-123', + 'valid-token', + ); + + expect(result).toEqual([]); + }); + + it('should handle missing org in auth response for organization shares', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'organization', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + // Mock auth to return no org + mockAuth.mockResolvedValue({ + userId: 'user-123', + orgId: null, + }); + + const result = await getMessages( + 'task-123', + 'org-456', + 'user-123', + 'valid-token', + ); + + expect(result).toEqual([]); + }); + }); + + describe('Normal authentication flow (without share token)', () => { + it('should use normal authorization when no share token provided', async () => { + mockAuthorizeAnalytics.mockResolvedValue({ + authUserId: 'user-123', + isAdmin: false, + }); + + const result = await getMessages('task-123', 'org-456', 'user-123'); + + expect(mockAuthorizeAnalytics).toHaveBeenCalledWith({ + requestedOrgId: 'org-456', + requestedUserId: 'user-123', + }); + expect(result).toHaveLength(2); + }); + + it('should not call share token validation when no share token provided', async () => { + mockAuthorizeAnalytics.mockResolvedValue({ + authUserId: 'user-123', + isAdmin: false, + }); + + await getMessages('task-123', 'org-456', 'user-123'); + + expect(mockIsValidShareToken).not.toHaveBeenCalled(); + expect(mockDb.select).not.toHaveBeenCalled(); + }); + + it('should handle personal account access (null orgId)', async () => { + mockAuthorizeAnalytics.mockResolvedValue({ + authUserId: 'user-123', + isAdmin: false, + }); + + const result = await getMessages('task-123', null, 'user-123'); + + expect(mockAuthorizeAnalytics).toHaveBeenCalledWith({ + requestedOrgId: null, + requestedUserId: 'user-123', + }); + expect(result).toHaveLength(2); + }); + }); + + describe('Query parameter handling', () => { + it('should build correct query for organization messages', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: 'org-456', + visibility: 'public', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + await getMessages('task-123', 'org-456', 'user-123', 'valid-token'); + + expect(mockAnalytics.query).toHaveBeenCalledWith({ + query: expect.stringContaining('orgId = {orgId: String}'), + format: 'JSONEachRow', + query_params: { + taskId: 'task-123', + orgId: 'org-456', + }, + }); + }); + + it('should build correct query for personal account messages', async () => { + mockIsValidShareToken.mockReturnValue(true); + mockIsShareExpired.mockReturnValue(false); + + const mockShare = { + shareToken: 'valid-token', + taskId: 'task-123', + orgId: null, + visibility: 'public', + expiresAt: new Date('2025-01-01'), + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ share: mockShare }]), + }), + }), + }); + + await getMessages('task-123', null, 'user-123', 'valid-token'); + + expect(mockAnalytics.query).toHaveBeenCalledWith({ + query: expect.stringContaining('orgId IS NULL'), + format: 'JSONEachRow', + query_params: { + taskId: 'task-123', + }, + }); + }); + }); +});