diff --git a/src/graphql/types/Chat/updater.ts b/src/graphql/types/Chat/updater.ts index a0b49f02229..8cf8f993a66 100644 --- a/src/graphql/types/Chat/updater.ts +++ b/src/graphql/types/Chat/updater.ts @@ -1,100 +1,111 @@ +import { eq } from "drizzle-orm"; +import { usersTable } from "~/src/drizzle/schema"; +import { chatMembershipsTable } from "~/src/drizzle/tables/chatMemberships"; +import type { chatsTable } from "~/src/drizzle/tables/chats"; +import { organizationMembershipsTable } from "~/src/drizzle/tables/organizationMemberships"; import { User } from "~/src/graphql/types/User/User"; import { TalawaGraphQLError } from "~/src/utilities/TalawaGraphQLError"; +import type { GraphQLContext } from "../../context"; import { Chat } from "./Chat"; +type ChatsTable = typeof chatsTable.$inferSelect; + +export const resolveUpdater = async ( + parent: ChatsTable, + _args: Record, + ctx: GraphQLContext, +) => { + if (!ctx.currentClient.isAuthenticated) { + throw new TalawaGraphQLError({ + extensions: { + code: "unauthenticated", + }, + }); + } + + const currentUserId = ctx.currentClient.user.id; + + const currentUser = await ctx.drizzleClient.query.usersTable.findFirst({ + with: { + chatMembershipsWhereMember: { + columns: { + role: true, + }, + where: eq(chatMembershipsTable.chatId, parent.id), + }, + organizationMembershipsWhereMember: { + columns: { + role: true, + }, + where: eq( + organizationMembershipsTable.organizationId, + parent.organizationId, + ), + }, + }, + where: eq(usersTable.id, currentUserId), + }); + + if (currentUser === undefined) { + throw new TalawaGraphQLError({ + extensions: { + code: "unauthenticated", + }, + }); + } + + const currentUserOrganizationMembership = + currentUser.organizationMembershipsWhereMember[0]; + const currentUserChatMembership = currentUser.chatMembershipsWhereMember[0]; + + const isGlobalAdmin = currentUser.role === "administrator"; + const isOrgAdmin = + currentUserOrganizationMembership?.role === "administrator"; + const isChatAdmin = currentUserChatMembership?.role === "administrator"; + + if (!isGlobalAdmin && !isOrgAdmin && !isChatAdmin) { + throw new TalawaGraphQLError({ + extensions: { + code: "unauthorized_action", + }, + }); + } + + if (parent.updaterId === null) { + return null; + } + + if (parent.updaterId === currentUserId) { + return currentUser; + } + + const updaterId = parent.updaterId; + + const existingUser = await ctx.drizzleClient.query.usersTable.findFirst({ + where: eq(usersTable.id, updaterId), + }); + + // Updater id existing but the associated user not existing is a business logic error and probably means that the corresponding data in the database is in a corrupted state. It must be investigated and fixed as soon as possible to prevent additional data corruption. + if (existingUser === undefined) { + const errorMessage = `Updater with ID ${updaterId} not found despite being referenced in chat ${parent.id}`; + ctx.log.error(errorMessage); + + throw new TalawaGraphQLError({ + extensions: { + code: "unexpected", + message: errorMessage, + }, + }); + } + + return existingUser; +}; + Chat.implement({ fields: (t) => ({ updater: t.field({ description: "User who last updated the chat.", - resolve: async (parent, _args, ctx) => { - if (!ctx.currentClient.isAuthenticated) { - throw new TalawaGraphQLError({ - extensions: { - code: "unauthenticated", - }, - }); - } - - const currentUserId = ctx.currentClient.user.id; - - const currentUser = await ctx.drizzleClient.query.usersTable.findFirst({ - with: { - chatMembershipsWhereMember: { - columns: { - role: true, - }, - where: (fields, operators) => - operators.eq(fields.chatId, parent.id), - }, - organizationMembershipsWhereMember: { - columns: { - role: true, - }, - where: (fields, operators) => - operators.eq(fields.organizationId, parent.organizationId), - }, - }, - where: (fields, operators) => operators.eq(fields.id, currentUserId), - }); - - if (currentUser === undefined) { - throw new TalawaGraphQLError({ - extensions: { - code: "unauthenticated", - }, - }); - } - - const currentUserOrganizationMembership = - currentUser.organizationMembershipsWhereMember[0]; - const currentUserChatMembership = - currentUser.chatMembershipsWhereMember[0]; - - if ( - currentUser.role !== "administrator" && - (currentUserOrganizationMembership === undefined || - (currentUserOrganizationMembership.role !== "administrator" && - (currentUserChatMembership === undefined || - currentUserChatMembership.role !== "administrator"))) - ) { - throw new TalawaGraphQLError({ - extensions: { - code: "unauthorized_action", - }, - }); - } - - if (parent.updaterId === null) { - return null; - } - - if (parent.updaterId === currentUserId) { - return currentUser; - } - - const updaterId = parent.updaterId; - - const existingUser = await ctx.drizzleClient.query.usersTable.findFirst( - { - where: (fields, operators) => operators.eq(fields.id, updaterId), - }, - ); - - // Updater id existing but the associated user not existing is a business logic error and probably means that the corresponding data in the database is in a corrupted state. It must be investigated and fixed as soon as possible to prevent additional data corruption. - if (existingUser === undefined) { - ctx.log.error( - "Postgres select operation returned an empty array for a chat's updater id that isn't null.", - ); - - throw new TalawaGraphQLError({ - extensions: { - code: "unexpected", - }, - }); - } - - return existingUser; - }, + resolve: resolveUpdater, type: User, }), }), diff --git a/src/graphql/types/Chat/updatedAt.spec.ts b/test/graphql/types/Chat/updatedAt.test.ts similarity index 75% rename from src/graphql/types/Chat/updatedAt.spec.ts rename to test/graphql/types/Chat/updatedAt.test.ts index d40e60173c5..b8a2adecd02 100644 --- a/src/graphql/types/Chat/updatedAt.spec.ts +++ b/test/graphql/types/Chat/updatedAt.test.ts @@ -1,8 +1,37 @@ import type { FastifyInstance } from "fastify"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { type Mock, vi } from "vitest"; +import type { z } from "zod"; +import type { chatMembershipRoleEnum } from "~/src/drizzle/enums/chatMembershipRole"; +import type { organizationMembershipRoleEnum } from "~/src/drizzle/enums/organizationMembershipRole"; +import type { userRoleEnum } from "~/src/drizzle/enums/userRole"; import { TalawaGraphQLError } from "~/src/utilities/TalawaGraphQLError"; -import type { PubSub } from "../../pubsub"; -import { resolveUpdatedAt } from "./updatedAt"; +import type { PubSub } from "../../../../src/graphql/pubsub"; +import { resolveUpdatedAt } from "../../../../src/graphql/types/Chat/updatedAt"; + +// Infer types from the zod enums +type UserRole = z.infer; +type ChatMembershipRole = z.infer; +type OrganizationMembershipRole = z.infer< + typeof organizationMembershipRoleEnum +>; + +type MockUser = { + role: UserRole; + chatMembershipsWhereMember: Array<{ role: ChatMembershipRole }>; + organizationMembershipsWhereMember: Array<{ + role: OrganizationMembershipRole; + }>; +}; + +type MockDrizzleClient = { + query: { + usersTable: { + findFirst: Mock<() => Promise>; + }; + }; +}; + const mockParent = { id: "chat_1", organizationId: "org_1", @@ -15,13 +44,14 @@ const mockParent = { avatarName: "avatar_name", updaterId: "updater_1", }; + const drizzleClientMock = { query: { usersTable: { - findFirst: vi.fn(), + findFirst: vi.fn().mockImplementation(() => Promise.resolve(undefined)), }, }, -} as unknown as FastifyInstance["drizzleClient"]; +} as unknown as FastifyInstance["drizzleClient"] & MockDrizzleClient; const authenticatedContext = { currentClient: { @@ -62,7 +92,6 @@ describe("Chat.updatedAt resolver", () => { }); it("throws unauthenticated error when user is not found", async () => { - // @ts-ignore drizzleClientMock.query.usersTable.findFirst.mockResolvedValue(undefined); await expect( @@ -71,7 +100,6 @@ describe("Chat.updatedAt resolver", () => { }); it("throws unauthorized error when user lacks permissions", async () => { - // @ts-ignore drizzleClientMock.query.usersTable.findFirst.mockResolvedValue({ role: "regular", chatMembershipsWhereMember: [], @@ -84,7 +112,6 @@ describe("Chat.updatedAt resolver", () => { }); it("returns updatedAt when user is global admin", async () => { - // @ts-ignore drizzleClientMock.query.usersTable.findFirst.mockResolvedValue({ role: "administrator", chatMembershipsWhereMember: [], @@ -96,7 +123,6 @@ describe("Chat.updatedAt resolver", () => { }); it("returns updatedAt when user is an administrator in the organization and not global administrator", async () => { - // @ts-ignore drizzleClientMock.query.usersTable.findFirst.mockResolvedValue({ role: "regular", chatMembershipsWhereMember: [{ role: "regular" }], @@ -106,8 +132,8 @@ describe("Chat.updatedAt resolver", () => { const result = await resolveUpdatedAt(mockParent, {}, authenticatedContext); expect(result).toEqual(mockParent.updatedAt); }); + it("returns updatedAt when user is a chat administrator and not global administrator", async () => { - // @ts-ignore drizzleClientMock.query.usersTable.findFirst.mockResolvedValue({ role: "regular", chatMembershipsWhereMember: [{ role: "administrator" }], @@ -117,8 +143,8 @@ describe("Chat.updatedAt resolver", () => { const result = await resolveUpdatedAt(mockParent, {}, authenticatedContext); expect(result).toEqual(mockParent.updatedAt); }); + it("returns updatedAt when user is both organization and chat admin", async () => { - // @ts-ignore drizzleClientMock.query.usersTable.findFirst.mockResolvedValue({ role: "regular", chatMembershipsWhereMember: [{ role: "administrator" }], diff --git a/test/graphql/types/Chat/updater.test.ts b/test/graphql/types/Chat/updater.test.ts new file mode 100644 index 00000000000..32dfb937b07 --- /dev/null +++ b/test/graphql/types/Chat/updater.test.ts @@ -0,0 +1,262 @@ +import type { FastifyInstance } from "fastify"; +import { beforeEach, describe, expect, it } from "vitest"; +import { type Mock, vi } from "vitest"; +import type { z } from "zod"; +import type { chatMembershipRoleEnum } from "~/src/drizzle/enums/chatMembershipRole"; +import type { organizationMembershipRoleEnum } from "~/src/drizzle/enums/organizationMembershipRole"; +import type { userRoleEnum } from "~/src/drizzle/enums/userRole"; +import type { PubSub } from "../../../../src/graphql/pubsub"; +import { resolveUpdater } from "../../../../src/graphql/types/Chat/updater"; + +// Infer types from the zod enums +type UserRole = z.infer; +type ChatMembershipRole = z.infer; +type OrganizationMembershipRole = z.infer< + typeof organizationMembershipRoleEnum +>; + +type MockUser = { + id: string; + role: UserRole; + chatMembershipsWhereMember: Array<{ role: ChatMembershipRole }>; + organizationMembershipsWhereMember: Array<{ + role: OrganizationMembershipRole; + }>; +}; + +type MockDrizzleClient = { + query: { + usersTable: { + findFirst: Mock<(params?: unknown) => Promise>; + }; + }; +}; + +const mockCurrentUser: MockUser = { + id: "user_1", + role: "regular" as UserRole, + chatMembershipsWhereMember: [], + organizationMembershipsWhereMember: [], +}; + +const mockUpdaterUser: MockUser = { + id: "updater_1", + role: "regular" as UserRole, + chatMembershipsWhereMember: [], + organizationMembershipsWhereMember: [], +}; + +const mockParent = { + id: "chat_1", + organizationId: "org_1", + updatedAt: new Date("2023-10-01T00:00:00Z"), + createdAt: new Date("2023-10-01T00:00:00Z"), + name: "Amaan", + description: "chat description", + creatorId: "creator_1", + avatarMimeType: null, + avatarName: "avatar_name", + updaterId: "updater_1", +}; + +const drizzleClientMock = { + query: { + usersTable: { + findFirst: vi.fn().mockImplementation(() => Promise.resolve(undefined)), + }, + }, +} as unknown as FastifyInstance["drizzleClient"] & MockDrizzleClient; + +const mockLogger = { + error: vi.fn(), +} as unknown as FastifyInstance["log"]; + +const authenticatedContext = { + currentClient: { + isAuthenticated: true as const, + user: { + id: "user_1", + }, + }, + drizzleClient: drizzleClientMock, + envConfig: { API_BASE_URL: "API_BASE" }, + log: mockLogger, + minio: {} as unknown as FastifyInstance["minio"], + jwt: { + sign: vi.fn(), + }, + pubsub: {} as unknown as PubSub, +}; + +const unauthenticatedContext = { + ...authenticatedContext, + currentClient: { + isAuthenticated: false as const, + }, +}; + +describe("Chat.updater resolver", () => { + beforeEach(() => vi.resetAllMocks()); + + it("throws unauthenticated error when user is not authenticated", async () => { + await expect( + resolveUpdater(mockParent, {}, unauthenticatedContext), + ).rejects.toThrow( + expect.objectContaining({ + extensions: { code: "unauthenticated" }, + }), + ); + }); + + it("throws unauthenticated error when user is not found", async () => { + drizzleClientMock.query.usersTable.findFirst.mockImplementation(() => + Promise.resolve(undefined), + ); + + await expect( + resolveUpdater(mockParent, {}, authenticatedContext), + ).rejects.toThrow( + expect.objectContaining({ + extensions: { code: "unauthenticated" }, + }), + ); + }); + + it("throws unauthorized error when user lacks permissions", async () => { + drizzleClientMock.query.usersTable.findFirst.mockImplementation(() => + Promise.resolve({ + ...mockCurrentUser, + role: "regular" as UserRole, + chatMembershipsWhereMember: [], + organizationMembershipsWhereMember: [], + }), + ); + + await expect( + resolveUpdater(mockParent, {}, authenticatedContext), + ).rejects.toThrow( + expect.objectContaining({ + extensions: { code: "unauthorized_action" }, + }), + ); + }); + + it("returns null when updaterId is null", async () => { + drizzleClientMock.query.usersTable.findFirst.mockImplementation(() => + Promise.resolve({ + ...mockCurrentUser, + role: "administrator" as UserRole, + }), + ); + + const parentWithNullUpdater = { ...mockParent, updaterId: null }; + + const result = await resolveUpdater( + parentWithNullUpdater, + {}, + authenticatedContext, + ); + expect(result).toBeNull(); + }); + + it("returns current user when updaterId matches current user", async () => { + const currentUserWithPermissions = { + ...mockCurrentUser, + role: "administrator" as UserRole, + }; + + drizzleClientMock.query.usersTable.findFirst.mockImplementation(() => + Promise.resolve(currentUserWithPermissions), + ); + + const parentWithCurrentUserAsUpdater = { + ...mockParent, + updaterId: "user_1", + }; + + const result = await resolveUpdater( + parentWithCurrentUserAsUpdater, + {}, + authenticatedContext, + ); + expect(result).toEqual(currentUserWithPermissions); + }); + + it("throws unexpected error when updaterId exists but user is not found", async () => { + // Mock implementation for findFirst + // First call returns current user with admin role + // Second call returns undefined (updater not found) + drizzleClientMock.query.usersTable.findFirst + .mockImplementationOnce(() => + Promise.resolve({ + ...mockCurrentUser, + role: "administrator" as UserRole, + }), + ) + .mockImplementationOnce(() => Promise.resolve(undefined)); + + await expect( + resolveUpdater(mockParent, {}, authenticatedContext), + ).rejects.toThrow( + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unexpected", + message: expect.stringContaining( + `Updater with ID ${mockParent.updaterId}`, + ), + }), + }), + ); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + describe("admin role authorization", () => { + const adminRoles = [ + { + scenario: "global admin", + user: { + ...mockCurrentUser, + role: "administrator" as UserRole, + }, + }, + { + scenario: "organization admin", + user: { + ...mockCurrentUser, + role: "regular" as UserRole, + organizationMembershipsWhereMember: [ + { role: "administrator" as OrganizationMembershipRole }, + ], + }, + }, + { + scenario: "chat admin", + user: { + ...mockCurrentUser, + role: "regular" as UserRole, + chatMembershipsWhereMember: [ + { role: "administrator" as ChatMembershipRole }, + ], + }, + }, + ]; + + for (const { scenario, user } of adminRoles) { + it(`returns updater when user is a ${scenario}`, async () => { + // Mock implementation for findFirst + // First call returns current user with appropriate admin role + // Second call returns updater user + drizzleClientMock.query.usersTable.findFirst + .mockImplementationOnce(() => Promise.resolve(user)) + .mockImplementationOnce(() => Promise.resolve(mockUpdaterUser)); + + const result = await resolveUpdater( + mockParent, + {}, + authenticatedContext, + ); + expect(result).toEqual(mockUpdaterUser); + }); + } + }); +}); diff --git a/src/graphql/types/Event/updater.spec.ts b/test/graphql/types/Event/updater.test.ts similarity index 61% rename from src/graphql/types/Event/updater.spec.ts rename to test/graphql/types/Event/updater.test.ts index 38a43b5e762..5d6cf415154 100644 --- a/src/graphql/types/Event/updater.spec.ts +++ b/test/graphql/types/Event/updater.test.ts @@ -1,7 +1,28 @@ import type { FastifyInstance } from "fastify"; -import { describe, expect, it, vi } from "vitest"; -import type { PubSub } from "../../pubsub"; -import { resolveEventUpdater } from "./updater"; +import { describe, expect, it } from "vitest"; +import { type Mock, vi } from "vitest"; +import type { z } from "zod"; +import type { userRoleEnum } from "~/src/drizzle/enums/userRole"; +import type { PubSub } from "../../../../src/graphql/pubsub"; +import { resolveEventUpdater } from "../../../../src/graphql/types/Event/updater"; + +// Define types for the user object structure +type UserRole = z.infer; +type UserObject = { + id: string; + role: UserRole; + organizationMembershipsWhereMember: Array<{ role: UserRole }>; +}; + +// Define the type for the mock DrizzleClient based on usage +type MockDrizzleClient = { + query: { + usersTable: { + findFirst: Mock<() => Promise>; + }; + }; +}; + const MockEvent = { createdAt: new Date(), creatorId: "user_1", @@ -15,13 +36,14 @@ const MockEvent = { updaterId: "updater_1", }; +// Create a properly typed mock for drizzleClient const drizzleClientMock = { query: { usersTable: { - findFirst: vi.fn(), + findFirst: vi.fn().mockImplementation(() => Promise.resolve(undefined)), }, }, -} as unknown as FastifyInstance["drizzleClient"]; +} as unknown as FastifyInstance["drizzleClient"] & MockDrizzleClient; const authenticatedContext = { currentClient: { @@ -33,7 +55,7 @@ const authenticatedContext = { drizzleClient: drizzleClientMock, envConfig: { API_BASE_URL: "API_BASE" }, log: { error: vi.fn() } as unknown as FastifyInstance["log"], - minio: {} as unknown as FastifyInstance["minio"], + minio: {} as FastifyInstance["minio"], jwt: { sign: vi.fn(), }, @@ -62,9 +84,11 @@ describe("resolveEventUpdater", async () => { }), ); }); + it("throws an unauthenticated error if the current user is not found", async () => { - // @ts-ignore - drizzleClientMock.query.usersTable.findFirst.mockReturnValue(undefined); + drizzleClientMock.query.usersTable.findFirst.mockImplementation(() => + Promise.resolve(undefined), + ); await expect( resolveEventUpdater(MockEvent, {}, authenticatedContext), @@ -74,12 +98,14 @@ describe("resolveEventUpdater", async () => { }), ); }); + it("throws unauthorized_action error if current user is not administrator", async () => { - // @ts-ignore - drizzleClientMock.query.usersTable.findFirst.mockReturnValue({ + drizzleClientMock.query.usersTable.findFirst.mockResolvedValue({ + id: "user_1", role: "regular", - organizationMembershipsWhereMember: [{}], + organizationMembershipsWhereMember: [{ role: "regular" }], }); + await expect( resolveEventUpdater(MockEvent, {}, authenticatedContext), ).rejects.toThrowError( @@ -93,9 +119,10 @@ describe("resolveEventUpdater", async () => { }), ); }); + it("should return user as null if event (parent) has no updaterId ", async () => { - //@ts-ignore - drizzleClientMock.query.usersTable.findFirst.mockReturnValue({ + drizzleClientMock.query.usersTable.findFirst.mockResolvedValue({ + id: "user_1", role: "administrator", organizationMembershipsWhereMember: [{ role: "administrator" }], }); @@ -103,7 +130,7 @@ describe("resolveEventUpdater", async () => { const eventWithoutUpdater = { ...MockEvent, updaterId: null, - }; // event with no updater id + }; const result = await resolveEventUpdater( eventWithoutUpdater, @@ -112,15 +139,40 @@ describe("resolveEventUpdater", async () => { ); expect(result).toBeNull(); }); + + it("returns the current user if updaterId matches current user's id", async () => { + const mockCurrentUser: UserObject = { + id: "user_1", + role: "administrator", + organizationMembershipsWhereMember: [{ role: "administrator" }], + }; + + // Mock the event with updaterId matching the current user's id + const eventWithCurrentUserAsUpdater = { + ...MockEvent, + updaterId: "user_1", // Same as mockCurrentUser.id + }; + + drizzleClientMock.query.usersTable.findFirst.mockResolvedValue( + mockCurrentUser, + ); + + const result = await resolveEventUpdater( + eventWithCurrentUserAsUpdater, + {}, + authenticatedContext, + ); + expect(result).toEqual(mockCurrentUser); + }); + it("returns the currentUser if user is global administrator and event has updaterId", async () => { - // not mocking all the values of the user - const MockUser = { - id: "updater_1", // user with the id same as MockEvent.updaterId + const MockUser: UserObject = { + id: "updater_1", role: "administrator", organizationMembershipsWhereMember: [{ role: "regular" }], }; - // @ts-ignore - drizzleClientMock.query.usersTable.findFirst.mockReturnValue(MockUser); + + drizzleClientMock.query.usersTable.findFirst.mockResolvedValue(MockUser); const result = await resolveEventUpdater( MockEvent, @@ -129,35 +181,38 @@ describe("resolveEventUpdater", async () => { ); expect(result).toEqual(MockUser); }); + it("return currentUser if user is not the global administrator but organization administrator and event has updaterId", async () => { - // not mocking all the values of the user - const MockUser = { - id: "updater_1", // id matches with Event.updaterId + const MockUser: UserObject = { + id: "updater_1", role: "regular", organizationMembershipsWhereMember: [{ role: "administrator" }], }; - // @ts-ignore - drizzleClientMock.query.usersTable.findFirst.mockReturnValue(MockUser); - expect( - resolveEventUpdater(MockEvent, {}, authenticatedContext), - ).resolves.toEqual(MockUser); + + drizzleClientMock.query.usersTable.findFirst.mockResolvedValue(MockUser); + + const result = await resolveEventUpdater( + MockEvent, + {}, + authenticatedContext, + ); + expect(result).toEqual(MockUser); }); it("returns the updater user if updaterId differs from current user's id", async () => { - const mockCurrentUser = { + const mockCurrentUser: UserObject = { id: "user_1", role: "administrator", organizationMembershipsWhereMember: [{ role: "administrator" }], }; - // user which updated the event - const mockUpdaterUser = { + + const mockUpdaterUser: UserObject = { id: "updater_1", role: "administrator", - organizationMembershipsWhereMember: [], + organizationMembershipsWhereMember: [{ role: "regular" }], }; - // The first call returns the current user, and the second returns the updater user + drizzleClientMock.query.usersTable.findFirst - // @ts-ignore .mockResolvedValueOnce(mockCurrentUser) .mockResolvedValueOnce(mockUpdaterUser); @@ -170,14 +225,13 @@ describe("resolveEventUpdater", async () => { }); it("throws unexpected error when the updaterId user does not exist", async () => { - const mockCurrentUser = { + const mockCurrentUser: UserObject = { id: "user_1", role: "administrator", - organizationMembershipsWhereMember: [{ role: "administrator " }], + organizationMembershipsWhereMember: [{ role: "administrator" }], }; drizzleClientMock.query.usersTable.findFirst - // @ts-ignore .mockResolvedValueOnce(mockCurrentUser) .mockResolvedValueOnce(undefined);