diff --git a/services/platform/convex/branding/__tests__/queries.test.ts b/services/platform/convex/branding/__tests__/queries.test.ts index e3f7776215..a1ff9978ee 100644 --- a/services/platform/convex/branding/__tests__/queries.test.ts +++ b/services/platform/convex/branding/__tests__/queries.test.ts @@ -155,4 +155,35 @@ describe('getBrandingHandler', () => { accentColor: undefined, }); }); + + it('returns null for URLs when storage fetch fails', async () => { + const { ctx } = createMockQueryCtx({ + _id: 'branding_1', + organizationId: 'org_1', + appName: 'Acme', + logoStorageId: 'storage_logo', + faviconLightStorageId: 'storage_fav_light', + faviconDarkStorageId: 'storage_fav_dark', + updatedAt: 1000, + }); + + ctx.storage.getUrl + .mockResolvedValueOnce('https://storage.example.com/storage_logo') + .mockRejectedValueOnce(new Error('Storage unavailable')) + .mockResolvedValueOnce('https://storage.example.com/storage_fav_dark'); + + const result = await getBrandingHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual({ + appName: 'Acme', + textLogo: undefined, + logoUrl: 'https://storage.example.com/storage_logo', + faviconLightUrl: null, + faviconDarkUrl: 'https://storage.example.com/storage_fav_dark', + brandColor: undefined, + accentColor: undefined, + }); + }); }); diff --git a/services/platform/convex/branding/queries.ts b/services/platform/convex/branding/queries.ts index a9741b9ae3..3fbe70a105 100644 --- a/services/platform/convex/branding/queries.ts +++ b/services/platform/convex/branding/queries.ts @@ -22,14 +22,24 @@ export async function getBrandingHandler( if (!branding) return null; + async function safeGetUrl(storageId: string | undefined) { + if (!storageId) return null; + try { + return await ctx.storage.getUrl(storageId); + } catch (error) { + console.warn( + '[Branding] Failed to resolve storage URL', + storageId, + error, + ); + return null; + } + } + const [logoUrl, faviconLightUrl, faviconDarkUrl] = await Promise.all([ - branding.logoStorageId ? ctx.storage.getUrl(branding.logoStorageId) : null, - branding.faviconLightStorageId - ? ctx.storage.getUrl(branding.faviconLightStorageId) - : null, - branding.faviconDarkStorageId - ? ctx.storage.getUrl(branding.faviconDarkStorageId) - : null, + safeGetUrl(branding.logoStorageId), + safeGetUrl(branding.faviconLightStorageId), + safeGetUrl(branding.faviconDarkStorageId), ]); return { diff --git a/services/platform/convex/integrations/__tests__/queries.test.ts b/services/platform/convex/integrations/__tests__/queries.test.ts new file mode 100644 index 0000000000..ab246bb579 --- /dev/null +++ b/services/platform/convex/integrations/__tests__/queries.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { Doc } from '../../_generated/dataModel'; + +vi.mock('../../_generated/api', () => ({ + components: { + betterAuth: { + adapter: { + findMany: 'betterAuth:adapter:findMany', + findOne: 'betterAuth:adapter:findOne', + }, + }, + }, +})); + +vi.mock('../../lib/rls', () => ({ + getAuthUserIdentity: vi.fn(), + getOrganizationMember: vi.fn(), +})); + +vi.mock('../../lib/rls/errors', () => { + class RLSError extends Error { + constructor( + message: string, + public code: string, + ) { + super(message); + this.name = 'RLSError'; + } + } + return { + UnauthorizedError: class extends RLSError { + constructor() { + super('Not authorized to access this resource', 'UNAUTHORIZED'); + } + }, + }; +}); + +vi.mock('convex/values', () => { + const stub = () => 'validator'; + return { + v: { + string: stub, + number: stub, + boolean: stub, + optional: stub, + union: stub, + object: stub, + literal: stub, + array: stub, + null: stub, + id: stub, + }, + }; +}); + +vi.mock('../validators', () => ({ + integrationDocValidator: 'integrationDocValidator', +})); + +vi.mock('../get_integration', () => ({ + getIntegration: vi.fn(), +})); + +vi.mock('../get_integration_by_name', () => ({ + getIntegrationByName: vi.fn(), +})); + +vi.mock('../list_integrations', () => ({ + listIntegrations: vi.fn(), +})); + +vi.mock('../../_generated/server', async (importOriginal) => { + const mod = await importOriginal>(); + return { + ...mod, + query: (config: Record) => config, + }; +}); + +const { getAuthUserIdentity, getOrganizationMember } = + await import('../../lib/rls'); +const { UnauthorizedError } = await import('../../lib/rls/errors'); +const { getIntegration } = await import('../get_integration'); +const { getIntegrationByName } = await import('../get_integration_by_name'); +const { listIntegrations } = await import('../list_integrations'); + +const mockedGetAuthUser = vi.mocked(getAuthUserIdentity); +const mockedGetOrgMember = vi.mocked(getOrganizationMember); +const mockedGetIntegration = vi.mocked(getIntegration); +const mockedGetIntegrationByName = vi.mocked(getIntegrationByName); +const mockedListIntegrations = vi.mocked(listIntegrations); + +function createMockCtx() { + return { + runQuery: vi.fn(), + storage: { getUrl: vi.fn() }, + db: {}, + auth: {}, + }; +} + +function makeIntegrationDoc( + overrides: Record = {}, +): Doc<'integrations'> { + return { + _id: 'int_1', + _creationTime: 1000, + organizationId: 'org_1', + name: 'Test Integration', + type: 'rest_api', + iconStorageId: undefined, + ...overrides, + } as unknown as Doc<'integrations'>; +} + +describe('get handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns null when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + const { get } = await import('../queries'); + const handler = (get as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { integrationId: 'int_1' }); + + expect(result).toBeNull(); + }); + + it('returns null when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetIntegration.mockResolvedValue(makeIntegrationDoc()); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const ctx = createMockCtx(); + const { get } = await import('../queries'); + const handler = (get as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { integrationId: 'int_1' }); + + expect(result).toBeNull(); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetIntegration.mockResolvedValue(makeIntegrationDoc()); + mockedGetOrgMember.mockRejectedValue(new Error('DB failure')); + const ctx = createMockCtx(); + const { get } = await import('../queries'); + const handler = (get as unknown as { handler: Function }).handler; + + await expect(handler(ctx, { integrationId: 'int_1' })).rejects.toThrow( + 'DB failure', + ); + }); + + it('returns integration with icon URL on success', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const doc = makeIntegrationDoc({ iconStorageId: 'storage_icon' }); + mockedGetIntegration.mockResolvedValue(doc); + mockedGetOrgMember.mockResolvedValue({} as never); + const ctx = createMockCtx(); + ctx.storage.getUrl.mockResolvedValue('https://storage.example.com/icon'); + const { get } = await import('../queries'); + const handler = (get as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { integrationId: 'int_1' }); + + expect(result).toMatchObject({ + _id: 'int_1', + iconUrl: 'https://storage.example.com/icon', + }); + }); + + it('returns null iconUrl when storage fetch fails', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const doc = makeIntegrationDoc({ iconStorageId: 'storage_icon' }); + mockedGetIntegration.mockResolvedValue(doc); + mockedGetOrgMember.mockResolvedValue({} as never); + const ctx = createMockCtx(); + ctx.storage.getUrl.mockRejectedValue(new Error('Storage unavailable')); + const { get } = await import('../queries'); + const handler = (get as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { integrationId: 'int_1' }); + + expect(result).toMatchObject({ + _id: 'int_1', + iconUrl: null, + }); + }); +}); + +describe('getByName handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns null when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + const { getByName } = await import('../queries'); + const handler = (getByName as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { + organizationId: 'org_1', + name: 'My Integration', + }); + + expect(result).toBeNull(); + }); + + it('returns null when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const ctx = createMockCtx(); + const { getByName } = await import('../queries'); + const handler = (getByName as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { + organizationId: 'org_1', + name: 'My Integration', + }); + + expect(result).toBeNull(); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new Error('DB failure')); + const ctx = createMockCtx(); + const { getByName } = await import('../queries'); + const handler = (getByName as unknown as { handler: Function }).handler; + + await expect( + handler(ctx, { organizationId: 'org_1', name: 'My Integration' }), + ).rejects.toThrow('DB failure'); + }); + + it('returns null when integration not found', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockResolvedValue({} as never); + mockedGetIntegrationByName.mockResolvedValue(null); + const ctx = createMockCtx(); + const { getByName } = await import('../queries'); + const handler = (getByName as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { + organizationId: 'org_1', + name: 'Nonexistent', + }); + + expect(result).toBeNull(); + }); + + it('returns integration with icon URL on success', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockResolvedValue({} as never); + const doc = makeIntegrationDoc({ iconStorageId: 'storage_icon' }); + mockedGetIntegrationByName.mockResolvedValue(doc); + const ctx = createMockCtx(); + ctx.storage.getUrl.mockResolvedValue('https://storage.example.com/icon'); + const { getByName } = await import('../queries'); + const handler = (getByName as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { + organizationId: 'org_1', + name: 'Test Integration', + }); + + expect(result).toMatchObject({ + _id: 'int_1', + iconUrl: 'https://storage.example.com/icon', + }); + }); +}); + +describe('list handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns empty array when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + const { list } = await import('../queries'); + const handler = (list as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toEqual([]); + }); + + it('returns empty array when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const ctx = createMockCtx(); + const { list } = await import('../queries'); + const handler = (list as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toEqual([]); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new Error('System error')); + const ctx = createMockCtx(); + const { list } = await import('../queries'); + const handler = (list as unknown as { handler: Function }).handler; + + await expect(handler(ctx, { organizationId: 'org_1' })).rejects.toThrow( + 'System error', + ); + }); + + it('returns integrations with icon URLs', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockResolvedValue({} as never); + const docs = [ + makeIntegrationDoc({ _id: 'int_1', iconStorageId: 'sid_1' }), + makeIntegrationDoc({ _id: 'int_2' }), + ]; + mockedListIntegrations.mockResolvedValue(docs); + const ctx = createMockCtx(); + ctx.storage.getUrl.mockResolvedValueOnce('https://storage.example.com/i1'); + const { list } = await import('../queries'); + const handler = (list as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toHaveLength(2); + expect(result[0].iconUrl).toBe('https://storage.example.com/i1'); + expect(result[1].iconUrl).toBeNull(); + }); +}); diff --git a/services/platform/convex/integrations/queries.ts b/services/platform/convex/integrations/queries.ts index 70a0086676..0a7e479d16 100644 --- a/services/platform/convex/integrations/queries.ts +++ b/services/platform/convex/integrations/queries.ts @@ -4,6 +4,7 @@ import type { Doc } from '../_generated/dataModel'; import { query, QueryCtx } from '../_generated/server'; import { getAuthUserIdentity, getOrganizationMember } from '../lib/rls'; +import { UnauthorizedError } from '../lib/rls/errors'; import { getIntegration } from './get_integration'; import { getIntegrationByName } from './get_integration_by_name'; import { listIntegrations } from './list_integrations'; @@ -15,9 +16,18 @@ async function withIconUrl( ctx: QueryCtx, integration: Doc<'integrations'>, ): Promise { - const iconUrl = integration.iconStorageId - ? await ctx.storage.getUrl(integration.iconStorageId) - : null; + let iconUrl: string | null = null; + if (integration.iconStorageId) { + try { + iconUrl = await ctx.storage.getUrl(integration.iconStorageId); + } catch (error) { + console.warn( + '[Integrations] Failed to resolve icon URL', + integration.iconStorageId, + error, + ); + } + } return { ...integration, iconUrl }; } @@ -39,8 +49,11 @@ export const get = query({ try { await getOrganizationMember(ctx, integration.organizationId, authUser); - } catch { - return null; + } catch (error) { + if (error instanceof UnauthorizedError) { + return null; + } + throw error; } return await withIconUrl(ctx, integration); @@ -61,8 +74,11 @@ export const getByName = query({ try { await getOrganizationMember(ctx, args.organizationId, authUser); - } catch { - return null; + } catch (error) { + if (error instanceof UnauthorizedError) { + return null; + } + throw error; } const integration = await getIntegrationByName(ctx, args); @@ -88,8 +104,11 @@ export const list = query({ try { await getOrganizationMember(ctx, args.organizationId, authUser); - } catch { - return []; + } catch (error) { + if (error instanceof UnauthorizedError) { + return []; + } + throw error; } const integrations = await listIntegrations(ctx, args); diff --git a/services/platform/convex/members/__tests__/queries.test.ts b/services/platform/convex/members/__tests__/queries.test.ts new file mode 100644 index 0000000000..61a3836d97 --- /dev/null +++ b/services/platform/convex/members/__tests__/queries.test.ts @@ -0,0 +1,510 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { QueryCtx } from '../../_generated/server'; + +vi.mock('../../_generated/api', () => ({ + components: { + betterAuth: { + adapter: { + findMany: 'betterAuth:adapter:findMany', + findOne: 'betterAuth:adapter:findOne', + }, + }, + }, +})); + +vi.mock('../../lib/rls', () => ({ + getAuthUserIdentity: vi.fn(), + getOrganizationMember: vi.fn(), + getUserOrganizations: vi.fn(), +})); + +vi.mock('../../lib/rls/errors', () => { + class RLSError extends Error { + constructor( + message: string, + public code: string, + ) { + super(message); + this.name = 'RLSError'; + } + } + return { + UnauthenticatedError: class extends RLSError { + constructor() { + super('Authentication required', 'UNAUTHENTICATED'); + } + }, + UnauthorizedError: class extends RLSError { + constructor() { + super('Not authorized to access this resource', 'UNAUTHORIZED'); + } + }, + }; +}); + +vi.mock('convex/values', () => { + const stub = () => 'validator'; + return { + v: { + string: stub, + number: stub, + boolean: stub, + optional: stub, + union: stub, + object: stub, + literal: stub, + array: stub, + null: stub, + id: stub, + }, + }; +}); + +vi.mock('../validators', () => ({ + memberRoleValidator: 'memberRoleValidator', +})); + +vi.mock('../../_generated/server', async (importOriginal) => { + const mod = await importOriginal>(); + return { + ...mod, + query: (config: Record) => config, + }; +}); + +const { getAuthUserIdentity, getOrganizationMember } = + await import('../../lib/rls'); +const { UnauthenticatedError, UnauthorizedError } = + await import('../../lib/rls/errors'); +const { + getMyTeamsHandler, + approxCountMyTeamsHandler, + listByOrganizationHandler, +} = await import('../queries'); + +const mockedGetAuthUser = vi.mocked(getAuthUserIdentity); +const mockedGetOrgMember = vi.mocked(getOrganizationMember); + +function createMockCtx() { + return { + runQuery: vi.fn(), + db: {}, + auth: {}, + }; +} + +describe('getCurrentMemberContext handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function getHandler() { + const { getCurrentMemberContext } = await import('../queries'); + return (getCurrentMemberContext as unknown as { handler: Function }) + .handler; + } + + it('throws UnauthenticatedError when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + const handler = await getHandler(); + + await expect( + handler(ctx, { organizationId: 'org_1' }), + ).rejects.toBeInstanceOf(UnauthenticatedError); + }); + + it('returns null when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1', name: 'Alice' }); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const ctx = createMockCtx(); + const handler = await getHandler(); + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toBeNull(); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1', name: 'Alice' }); + mockedGetOrgMember.mockRejectedValue(new Error('DB failure')); + const ctx = createMockCtx(); + const handler = await getHandler(); + + await expect(handler(ctx, { organizationId: 'org_1' })).rejects.toThrow( + 'DB failure', + ); + }); + + it('returns member context on success', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1', name: 'Alice' }); + mockedGetOrgMember.mockResolvedValue({ + _id: 'om_1', + organizationId: 'org_1', + userId: 'user_1', + role: 'admin', + createdAt: 1000, + }); + const ctx = createMockCtx(); + const handler = await getHandler(); + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toEqual({ + memberId: 'om_1', + organizationId: 'org_1', + userId: 'user_1', + role: 'admin', + createdAt: 1000, + displayName: 'Alice', + isAdmin: true, + }); + }); + + it('defaults to member role for invalid roles', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1', name: 'Bob' }); + mockedGetOrgMember.mockResolvedValue({ + _id: 'om_2', + organizationId: 'org_1', + userId: 'user_1', + role: 'superadmin', + createdAt: 2000, + }); + const ctx = createMockCtx(); + const handler = await getHandler(); + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toEqual({ + memberId: 'om_2', + organizationId: 'org_1', + userId: 'user_1', + role: 'member', + createdAt: 2000, + displayName: 'Bob', + isAdmin: false, + }); + }); +}); + +describe('getMyTeamsHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns empty array when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + + const result = await getMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([]); + }); + + it('returns empty array when user has no team memberships', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ page: [] }); + + const result = await getMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([]); + }); + + it('returns teams for user with memberships', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { _id: 'tm_1', teamId: 'team_1', userId: 'user_1' }, + { _id: 'tm_2', teamId: 'team_2', userId: 'user_1' }, + ], + }); + + ctx.runQuery + .mockResolvedValueOnce({ + page: [{ _id: 'team_1', name: 'Alpha', organizationId: 'org_1' }], + }) + .mockResolvedValueOnce({ + page: [{ _id: 'team_2', name: 'Beta', organizationId: 'org_1' }], + }); + + const result = await getMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([ + { id: 'team_1', name: 'Alpha' }, + { id: 'team_2', name: 'Beta' }, + ]); + }); + + it('returns partial results when some team lookups fail', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { _id: 'tm_1', teamId: 'team_1', userId: 'user_1' }, + { _id: 'tm_2', teamId: 'team_2', userId: 'user_1' }, + { _id: 'tm_3', teamId: 'team_3', userId: 'user_1' }, + ], + }); + + ctx.runQuery + .mockResolvedValueOnce({ + page: [{ _id: 'team_1', name: 'Alpha', organizationId: 'org_1' }], + }) + .mockRejectedValueOnce(new Error('DB connection lost')) + .mockResolvedValueOnce({ + page: [{ _id: 'team_3', name: 'Gamma', organizationId: 'org_1' }], + }); + + const result = await getMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([ + { id: 'team_1', name: 'Alpha' }, + { id: 'team_3', name: 'Gamma' }, + ]); + }); + + it('returns empty array when all team lookups fail', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { _id: 'tm_1', teamId: 'team_1', userId: 'user_1' }, + { _id: 'tm_2', teamId: 'team_2', userId: 'user_1' }, + ], + }); + + ctx.runQuery + .mockRejectedValueOnce(new Error('fail')) + .mockRejectedValueOnce(new Error('fail')); + + const result = await getMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([]); + }); +}); + +describe('approxCountMyTeamsHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 0 when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + + const result = await approxCountMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toBe(0); + }); + + it('counts teams correctly', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { _id: 'tm_1', teamId: 'team_1', userId: 'user_1' }, + { _id: 'tm_2', teamId: 'team_2', userId: 'user_1' }, + ], + }); + + ctx.runQuery + .mockResolvedValueOnce({ page: [{ _id: 'team_1' }] }) + .mockResolvedValueOnce({ page: [{ _id: 'team_2' }] }); + + const result = await approxCountMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toBe(2); + }); + + it('counts only successful lookups on partial failure', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { _id: 'tm_1', teamId: 'team_1', userId: 'user_1' }, + { _id: 'tm_2', teamId: 'team_2', userId: 'user_1' }, + { _id: 'tm_3', teamId: 'team_3', userId: 'user_1' }, + ], + }); + + ctx.runQuery + .mockResolvedValueOnce({ page: [{ _id: 'team_1' }] }) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce({ page: [{ _id: 'team_3' }] }); + + const result = await approxCountMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toBe(2); + }); +}); + +describe('listByOrganizationHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns empty array when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + + const result = await listByOrganizationHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([]); + }); + + it('returns empty array when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const ctx = createMockCtx(); + + const result = await listByOrganizationHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([]); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new Error('DB connection lost')); + const ctx = createMockCtx(); + + await expect( + listByOrganizationHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }), + ).rejects.toThrow('DB connection lost'); + }); + + it('returns members with user details', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockResolvedValue({ + _id: 'om_1', + createdAt: 1000, + organizationId: 'org_1', + userId: 'user_1', + role: 'admin', + }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { + _id: 'm_1', + organizationId: 'org_1', + userId: 'u_1', + role: 'admin', + createdAt: 1000, + }, + ], + }); + + ctx.runQuery.mockResolvedValueOnce({ + name: 'Alice', + email: 'alice@example.com', + }); + + const result = await listByOrganizationHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([ + { + _id: 'm_1', + organizationId: 'org_1', + userId: 'u_1', + role: 'admin', + createdAt: 1000, + displayName: 'Alice', + email: 'alice@example.com', + }, + ]); + }); + + it('returns member without name/email when user lookup fails', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockResolvedValue({ + _id: 'om_1', + createdAt: 1000, + organizationId: 'org_1', + userId: 'user_1', + role: 'admin', + }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { + _id: 'm_1', + organizationId: 'org_1', + userId: 'u_1', + role: 'member', + createdAt: 1000, + }, + { + _id: 'm_2', + organizationId: 'org_1', + userId: 'u_2', + role: 'admin', + createdAt: 2000, + }, + ], + }); + + ctx.runQuery + .mockRejectedValueOnce(new Error('lookup failed')) + .mockResolvedValueOnce({ name: 'Bob', email: 'bob@example.com' }); + + const result = await listByOrganizationHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([ + { + _id: 'm_1', + organizationId: 'org_1', + userId: 'u_1', + role: 'member', + createdAt: 1000, + displayName: undefined, + email: undefined, + }, + { + _id: 'm_2', + organizationId: 'org_1', + userId: 'u_2', + role: 'admin', + createdAt: 2000, + displayName: 'Bob', + email: 'bob@example.com', + }, + ]); + }); +}); diff --git a/services/platform/convex/members/queries.ts b/services/platform/convex/members/queries.ts index d8db221122..922270e72c 100644 --- a/services/platform/convex/members/queries.ts +++ b/services/platform/convex/members/queries.ts @@ -4,7 +4,7 @@ import type { MemberRole } from '../../lib/shared/schemas/organizations'; import type { BetterAuthFindManyResult, BetterAuthMember } from './types'; import { components } from '../_generated/api'; -import { query } from '../_generated/server'; +import { query, QueryCtx } from '../_generated/server'; import { getAuthUserIdentity, getOrganizationMember, @@ -86,6 +86,82 @@ export const getCurrentMemberContext = query({ }, }); +export async function listByOrganizationHandler( + ctx: QueryCtx, + args: { organizationId: string }, +) { + const authUser = await getAuthUserIdentity(ctx); + if (!authUser) { + return []; + } + + try { + await getOrganizationMember(ctx, args.organizationId, authUser); + } catch (error) { + if (error instanceof UnauthorizedError) { + return []; + } + throw error; + } + + const result: BetterAuthFindManyResult = await ctx.runQuery( + components.betterAuth.adapter.findMany, + { + model: 'member', + paginationOpts: { cursor: null, numItems: 100 }, + where: [ + { + field: 'organizationId', + value: args.organizationId, + operator: 'eq', + }, + ], + }, + ); + + if (!result || result.page.length === 0) { + return []; + } + + return Promise.all( + result.page.map(async (member) => { + let displayName: string | undefined; + let email: string | undefined; + try { + const userResult = await ctx.runQuery( + components.betterAuth.adapter.findOne, + { + model: 'user', + where: [{ field: '_id', value: member.userId, operator: 'eq' }], + }, + ); + displayName = userResult?.name; + email = userResult?.email; + } catch (error) { + console.warn( + '[Members] Failed to fetch user details', + member.userId, + error, + ); + } + + const role: MemberRole = isValidRole(member.role) + ? member.role + : 'member'; + + return { + _id: member._id, + organizationId: member.organizationId, + userId: member.userId, + role, + createdAt: member.createdAt, + displayName, + email, + }; + }), + ); +} + export const listByOrganization = query({ args: { organizationId: v.string() }, returns: v.array( @@ -99,61 +175,7 @@ export const listByOrganization = query({ email: v.optional(v.string()), }), ), - handler: async (ctx, args) => { - const authUser = await getAuthUserIdentity(ctx); - if (!authUser) { - return []; - } - - try { - await getOrganizationMember(ctx, args.organizationId, authUser); - } catch { - return []; - } - - const result: BetterAuthFindManyResult = - await ctx.runQuery(components.betterAuth.adapter.findMany, { - model: 'member', - paginationOpts: { cursor: null, numItems: 100 }, - where: [ - { - field: 'organizationId', - value: args.organizationId, - operator: 'eq', - }, - ], - }); - - if (!result || result.page.length === 0) { - return []; - } - - return Promise.all( - result.page.map(async (member) => { - const userResult = await ctx.runQuery( - components.betterAuth.adapter.findOne, - { - model: 'user', - where: [{ field: '_id', value: member.userId, operator: 'eq' }], - }, - ); - - const role: MemberRole = isValidRole(member.role) - ? member.role - : 'member'; - - return { - _id: member._id, - organizationId: member.organizationId, - userId: member.userId, - role, - createdAt: member.createdAt, - displayName: userResult?.name, - email: userResult?.email, - }; - }), - ); - }, + handler: listByOrganizationHandler, }); export const getUserIdByEmail = query({ @@ -198,32 +220,31 @@ export const getUserOrganizationsList = query({ }, }); -export const approxCountMyTeams = query({ - args: { - organizationId: v.string(), - }, - returns: v.number(), - handler: async (ctx, args) => { - const authUser = await getAuthUserIdentity(ctx); - if (!authUser) { - return 0; - } - - const membershipsResult: BetterAuthFindManyResult = - await ctx.runQuery(components.betterAuth.adapter.findMany, { - model: 'teamMember', - paginationOpts: { cursor: null, numItems: 100 }, - where: [{ field: 'userId', operator: 'eq', value: authUser.userId }], - }); +export async function approxCountMyTeamsHandler( + ctx: QueryCtx, + args: { organizationId: string }, +) { + const authUser = await getAuthUserIdentity(ctx); + if (!authUser) { + return 0; + } + + const membershipsResult: BetterAuthFindManyResult = + await ctx.runQuery(components.betterAuth.adapter.findMany, { + model: 'teamMember', + paginationOpts: { cursor: null, numItems: 100 }, + where: [{ field: 'userId', operator: 'eq', value: authUser.userId }], + }); - if (!membershipsResult || membershipsResult.page.length === 0) { - return 0; - } + if (!membershipsResult || membershipsResult.page.length === 0) { + return 0; + } - const teamResults: BetterAuthFindManyResult[] = - await Promise.all( - membershipsResult.page.map((membership) => - ctx.runQuery(components.betterAuth.adapter.findMany, { + const teamResults: (BetterAuthFindManyResult | null)[] = + await Promise.all( + membershipsResult.page.map(async (membership) => { + try { + return await ctx.runQuery(components.betterAuth.adapter.findMany, { model: 'team', paginationOpts: { cursor: null, numItems: 1 }, where: [ @@ -234,47 +255,54 @@ export const approxCountMyTeams = query({ value: args.organizationId, }, ], - }), - ), - ); + }); + } catch (error) { + console.warn( + '[Members] Failed to look up team', + membership.teamId, + error, + ); + return null; + } + }), + ); - return teamResults.filter((r) => r && r.page.length > 0).length; - }, -}); + return teamResults.filter((r) => r && r.page.length > 0).length; +} -export const getMyTeams = query({ - args: { - organizationId: v.string(), - }, - returns: v.array( - v.object({ - id: v.string(), - name: v.string(), - }), - ), - handler: async (ctx, args) => { - const authUser = await getAuthUserIdentity(ctx); - if (!authUser) { - return []; - } +export const approxCountMyTeams = query({ + args: { organizationId: v.string() }, + returns: v.number(), + handler: approxCountMyTeamsHandler, +}); - const membershipsResult: BetterAuthFindManyResult = - await ctx.runQuery(components.betterAuth.adapter.findMany, { - model: 'teamMember', - paginationOpts: { cursor: null, numItems: 100 }, - where: [{ field: 'userId', operator: 'eq', value: authUser.userId }], - }); +export async function getMyTeamsHandler( + ctx: QueryCtx, + args: { organizationId: string }, +) { + const authUser = await getAuthUserIdentity(ctx); + if (!authUser) { + return []; + } + + const membershipsResult: BetterAuthFindManyResult = + await ctx.runQuery(components.betterAuth.adapter.findMany, { + model: 'teamMember', + paginationOpts: { cursor: null, numItems: 100 }, + where: [{ field: 'userId', operator: 'eq', value: authUser.userId }], + }); - if (!membershipsResult || membershipsResult.page.length === 0) { - return []; - } + if (!membershipsResult || membershipsResult.page.length === 0) { + return []; + } - const teamIds = membershipsResult.page.map((m) => m.teamId); + const teamIds = membershipsResult.page.map((m) => m.teamId); - const teamResults: BetterAuthFindManyResult[] = - await Promise.all( - teamIds.map((teamId) => - ctx.runQuery(components.betterAuth.adapter.findMany, { + const teamResults: (BetterAuthFindManyResult | null)[] = + await Promise.all( + teamIds.map(async (teamId) => { + try { + return await ctx.runQuery(components.betterAuth.adapter.findMany, { model: 'team', paginationOpts: { cursor: null, numItems: 1 }, where: [ @@ -285,21 +313,30 @@ export const getMyTeams = query({ value: args.organizationId, }, ], - }), - ), - ); + }); + } catch (error) { + console.warn('[Members] Failed to look up team', teamId, error); + return null; + } + }), + ); - const teams: Array<{ id: string; name: string }> = []; - for (const teamResult of teamResults) { - if (teamResult && teamResult.page.length > 0) { - const team = teamResult.page[0]; - teams.push({ - id: team._id, - name: team.name, - }); - } + const teams: Array<{ id: string; name: string }> = []; + for (const teamResult of teamResults) { + if (teamResult && teamResult.page.length > 0) { + const team = teamResult.page[0]; + teams.push({ + id: team._id, + name: team.name, + }); } + } - return teams; - }, + return teams; +} + +export const getMyTeams = query({ + args: { organizationId: v.string() }, + returns: v.array(v.object({ id: v.string(), name: v.string() })), + handler: getMyTeamsHandler, }); diff --git a/services/platform/convex/team_members/__tests__/queries.test.ts b/services/platform/convex/team_members/__tests__/queries.test.ts new file mode 100644 index 0000000000..d3a906a8c7 --- /dev/null +++ b/services/platform/convex/team_members/__tests__/queries.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../_generated/api', () => ({ + components: { + betterAuth: { + adapter: { + findMany: 'betterAuth:adapter:findMany', + findOne: 'betterAuth:adapter:findOne', + }, + }, + }, +})); + +vi.mock('../../lib/rls', () => ({ + getAuthUserIdentity: vi.fn(), + getOrganizationMember: vi.fn(), +})); + +vi.mock('../../lib/rls/errors', () => { + class RLSError extends Error { + constructor( + message: string, + public code: string, + ) { + super(message); + this.name = 'RLSError'; + } + } + return { + UnauthorizedError: class extends RLSError { + constructor() { + super('Not authorized to access this resource', 'UNAUTHORIZED'); + } + }, + }; +}); + +vi.mock('convex/values', () => { + const stub = () => 'validator'; + return { + v: { + string: stub, + number: stub, + boolean: stub, + optional: stub, + union: stub, + object: stub, + literal: stub, + array: stub, + null: stub, + id: stub, + }, + }; +}); + +vi.mock('../../_generated/server', async (importOriginal) => { + const mod = await importOriginal>(); + return { + ...mod, + query: (config: Record) => config, + }; +}); + +const { getAuthUserIdentity, getOrganizationMember } = + await import('../../lib/rls'); +const { UnauthorizedError } = await import('../../lib/rls/errors'); + +const mockedGetAuthUser = vi.mocked(getAuthUserIdentity); +const mockedGetOrgMember = vi.mocked(getOrganizationMember); + +function createMockCtx() { + return { + runQuery: vi.fn(), + db: {}, + auth: {}, + }; +} + +describe('listByTeam handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function getHandler() { + const { listByTeam } = await import('../queries'); + return (listByTeam as unknown as { handler: Function }).handler; + } + + it('returns empty array when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + const handler = await getHandler(); + + const result = await handler(ctx, { teamId: 'team_1' }); + + expect(result).toEqual([]); + }); + + it('returns empty array when team not found', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + ctx.runQuery.mockResolvedValueOnce(null); + const handler = await getHandler(); + + const result = await handler(ctx, { teamId: 'team_1' }); + + expect(result).toEqual([]); + }); + + it('returns empty array when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + ctx.runQuery.mockResolvedValueOnce({ + _id: 'team_1', + organizationId: 'org_1', + }); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const handler = await getHandler(); + + const result = await handler(ctx, { teamId: 'team_1' }); + + expect(result).toEqual([]); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + ctx.runQuery.mockResolvedValueOnce({ + _id: 'team_1', + organizationId: 'org_1', + }); + mockedGetOrgMember.mockRejectedValue(new Error('DB failure')); + const handler = await getHandler(); + + await expect(handler(ctx, { teamId: 'team_1' })).rejects.toThrow( + 'DB failure', + ); + }); + + it('returns members with user details', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + _id: 'team_1', + organizationId: 'org_1', + }); + mockedGetOrgMember.mockResolvedValue({} as never); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { + _id: 'tm_1', + teamId: 'team_1', + userId: 'u_1', + role: 'admin', + createdAt: 1000, + }, + ], + }); + + ctx.runQuery.mockResolvedValueOnce({ + name: 'Alice', + email: 'alice@example.com', + }); + + const handler = await getHandler(); + const result = await handler(ctx, { teamId: 'team_1' }); + + expect(result).toEqual([ + { + _id: 'tm_1', + teamId: 'team_1', + userId: 'u_1', + role: 'admin', + joinedAt: 1000, + displayName: 'Alice', + email: 'alice@example.com', + }, + ]); + }); + + it('returns member without name/email when user lookup fails', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + _id: 'team_1', + organizationId: 'org_1', + }); + mockedGetOrgMember.mockResolvedValue({} as never); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { + _id: 'tm_1', + teamId: 'team_1', + userId: 'u_1', + role: 'member', + createdAt: 2000, + }, + ], + }); + + ctx.runQuery.mockRejectedValueOnce(new Error('lookup failed')); + + const handler = await getHandler(); + const result = await handler(ctx, { teamId: 'team_1' }); + + expect(result).toEqual([ + { + _id: 'tm_1', + teamId: 'team_1', + userId: 'u_1', + role: 'member', + joinedAt: 2000, + displayName: undefined, + email: undefined, + }, + ]); + }); +}); diff --git a/services/platform/convex/team_members/queries.ts b/services/platform/convex/team_members/queries.ts index 447afcd263..1c18b2d9dc 100644 --- a/services/platform/convex/team_members/queries.ts +++ b/services/platform/convex/team_members/queries.ts @@ -3,6 +3,7 @@ import { v } from 'convex/values'; import { components } from '../_generated/api'; import { query } from '../_generated/server'; import { getAuthUserIdentity, getOrganizationMember } from '../lib/rls'; +import { UnauthorizedError } from '../lib/rls/errors'; export const listByTeam = query({ args: { @@ -35,8 +36,11 @@ export const listByTeam = query({ try { await getOrganizationMember(ctx, team.organizationId, authUser); - } catch { - return []; + } catch (error) { + if (error instanceof UnauthorizedError) { + return []; + } + throw error; } const result = await ctx.runQuery(components.betterAuth.adapter.findMany, { @@ -66,19 +70,29 @@ export const listByTeam = query({ const userMap = new Map(); await Promise.all( [...userIds].map(async (userId) => { - const userResult = await ctx.runQuery( - components.betterAuth.adapter.findOne, - { - model: 'user', - where: [{ field: '_id', value: userId, operator: 'eq' }], - }, - ); - if (userResult) { - const name = - typeof userResult.name === 'string' ? userResult.name : undefined; - const email = - typeof userResult.email === 'string' ? userResult.email : undefined; - userMap.set(userId, { name, email }); + try { + const userResult = await ctx.runQuery( + components.betterAuth.adapter.findOne, + { + model: 'user', + where: [{ field: '_id', value: userId, operator: 'eq' }], + }, + ); + if (userResult) { + const name = + typeof userResult.name === 'string' ? userResult.name : undefined; + const email = + typeof userResult.email === 'string' + ? userResult.email + : undefined; + userMap.set(userId, { name, email }); + } + } catch (error) { + console.warn( + '[TeamMembers] Failed to fetch user details', + userId, + error, + ); } }), );