mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Add comprehensive authentication tests for PR #227
- Add thorough test coverage for authorizeAnalytics function - Add tests for authorizeShareToken and getTaskById with share token support - Add tests for authorizeMessageShareToken and getMessages with share token support - Cover security scenarios, error handling, and edge cases - Address @mrubens request for authentication code testing in modal hash navigation PR - Fix TypeScript any types to use unknown types for better type safety
This commit is contained in:
parent
1e265eaba3
commit
780cc07123
3 changed files with 1300 additions and 0 deletions
319
apps/web/src/actions/__tests__/auth.test.ts
Normal file
319
apps/web/src/actions/__tests__/auth.test.ts
Normal file
|
|
@ -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<typeof vi.fn>;
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
441
apps/web/src/actions/analytics/__tests__/events-auth.test.ts
Normal file
441
apps/web/src/actions/analytics/__tests__/events-auth.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
540
apps/web/src/actions/analytics/__tests__/messages-auth.test.ts
Normal file
540
apps/web/src/actions/analytics/__tests__/messages-auth.test.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue