Files
calendar/packages/trpc/server/routers/viewer/oAuth/updateClient.handler.test.ts
T

491 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TFunction } from "i18next";
import type { PrismaClient } from "@calcom/prisma";
import { OAuthClientStatus, UserPermissionRole } from "@calcom/prisma/enums";
import { updateClientHandler } from "./updateClient.handler";
const mocks = vi.hoisted(() => {
return {
findByClientId: vi.fn(),
findByClientIdIncludeUser: vi.fn(),
sendOAuthClientApprovedNotification: vi.fn(),
sendOAuthClientRejectedNotification: vi.fn(),
getTranslation: vi.fn(),
};
});
const CLIENT_ID = "client_123";
const OWNER_USER_ID = 42;
const USER_EMAIL = "admin@example.com";
const USER_NAME = "Admin Example";
const CLIENT_NAME = "My Client";
const CLIENT_PURPOSE = "My purpose";
const REDIRECT_URI = "https://example.com/callback";
const REJECTION_REASON_TRIMMED = "we dont support your usecase";
const REJECTION_REASON_RAW = " we dont support your usecase ";
vi.mock("@calcom/features/oauth/repositories/OAuthClientRepository", () => ({
OAuthClientRepository: class {
constructor() {}
findByClientId = mocks.findByClientId;
findByClientIdIncludeUser = mocks.findByClientIdIncludeUser;
},
}));
vi.mock("@calcom/emails/oauth-email-service", () => ({
sendOAuthClientApprovedNotification: mocks.sendOAuthClientApprovedNotification,
sendOAuthClientRejectedNotification: mocks.sendOAuthClientRejectedNotification,
}));
vi.mock("@calcom/i18n/server", () => ({
getTranslation: mocks.getTranslation,
}));
describe("updateClientHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("updates status to APPROVED (admin) and sends approved notification email with expected parameters", async () => {
const t = ((key: string, vars?: Record<string, unknown>) => {
if (key === "oauth_client_approved_email_subject") {
return `OAuth Client Approved: ${String(vars?.clientName ?? "")}`;
}
return key;
}) as unknown as TFunction;
mocks.getTranslation.mockResolvedValue(t);
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: {
email: USER_EMAIL,
name: USER_NAME,
},
});
const prismaUpdate = vi.fn().mockResolvedValue({
clientId: CLIENT_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
status: "APPROVED",
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
rejectionReason: null,
});
const ctx = {
user: {
id: 1,
role: UserPermissionRole.ADMIN,
},
prisma: {
oAuthClient: {
update: prismaUpdate,
},
} as unknown as PrismaClient,
};
const input = {
clientId: CLIENT_ID,
status: OAuthClientStatus.APPROVED,
};
const result = await updateClientHandler({ ctx, input });
expect(prismaUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { clientId: input.clientId },
data: {
status: "APPROVED",
rejectionReason: null,
},
})
);
expect(mocks.getTranslation).toHaveBeenCalledWith("en", "common");
expect(mocks.sendOAuthClientApprovedNotification).toHaveBeenCalledWith({
t,
userEmail: USER_EMAIL,
userName: USER_NAME,
clientName: CLIENT_NAME,
clientId: CLIENT_ID,
});
expect(result).toEqual({
clientId: CLIENT_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
status: "APPROVED",
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
rejectionReason: null,
});
});
it("updates status to REJECTED (admin) and sends rejected notification email with trimmed rejection reason", async () => {
const t = ((key: string, vars?: Record<string, unknown>) => {
if (key === "oauth_client_rejected_email_subject") {
return `OAuth Client Rejected: ${String(vars?.clientName ?? "")}`;
}
return key;
}) as unknown as TFunction;
mocks.getTranslation.mockResolvedValue(t);
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: {
email: USER_EMAIL,
name: USER_NAME,
},
});
const prismaUpdate = vi.fn().mockResolvedValue({
clientId: CLIENT_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
status: "REJECTED",
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
rejectionReason: REJECTION_REASON_TRIMMED,
});
const ctx = {
user: {
id: 1,
role: UserPermissionRole.ADMIN,
},
prisma: {
oAuthClient: {
update: prismaUpdate,
},
} as unknown as PrismaClient,
};
const input = {
clientId: CLIENT_ID,
status: OAuthClientStatus.REJECTED,
rejectionReason: REJECTION_REASON_RAW,
};
await updateClientHandler({ ctx, input });
expect(prismaUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { clientId: input.clientId },
data: {
status: "REJECTED",
rejectionReason: REJECTION_REASON_TRIMMED,
},
})
);
expect(mocks.getTranslation).toHaveBeenCalledWith("en", "common");
expect(mocks.sendOAuthClientRejectedNotification).toHaveBeenCalledWith({
t,
userEmail: USER_EMAIL,
userName: USER_NAME,
clientName: CLIENT_NAME,
clientId: CLIENT_ID,
rejectionReason: REJECTION_REASON_TRIMMED,
});
});
it("throws FORBIDDEN when a non-admin attempts to set rejectionReason", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: null,
});
const ctx = {
user: {
id: 2,
role: UserPermissionRole.USER,
},
prisma: {
oAuthClient: {
update: vi.fn(),
},
} as unknown as PrismaClient,
};
const input = {
clientId: CLIENT_ID,
rejectionReason: "nope",
};
await expect(updateClientHandler({ ctx, input })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Only admins can set a rejection reason",
});
});
it("throws BAD_REQUEST when rejecting without a non-empty rejectionReason", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: null,
});
const ctx = {
user: {
id: 1,
role: UserPermissionRole.ADMIN,
},
prisma: {
oAuthClient: {
update: vi.fn(),
},
} as unknown as PrismaClient,
};
await expect(
updateClientHandler({
ctx,
input: {
clientId: CLIENT_ID,
status: OAuthClientStatus.REJECTED,
rejectionReason: " ",
},
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "Rejection reason is required",
});
});
it("throws FORBIDDEN when a non-admin attempts to update status", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: null,
});
const ctx = {
user: {
id: 2,
role: UserPermissionRole.USER,
},
prisma: {
oAuthClient: {
update: vi.fn(),
},
} as unknown as PrismaClient,
};
await expect(
updateClientHandler({
ctx,
input: {
clientId: CLIENT_ID,
status: OAuthClientStatus.APPROVED,
},
})
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Only admins can update OAuth client status",
});
});
it("throws FORBIDDEN when a non-owner, non-admin attempts to update client fields", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: null,
});
const ctx = {
user: {
id: 999,
role: UserPermissionRole.USER,
},
prisma: {
oAuthClient: {
update: vi.fn(),
},
} as unknown as PrismaClient,
};
await expect(
updateClientHandler({
ctx,
input: {
clientId: CLIENT_ID,
name: "New Name",
},
})
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You do not have permission to update this OAuth client",
});
});
it("sets status to PENDING when an owner edits a previously approved client (reapproval flow)", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "APPROVED",
user: null,
});
const prismaUpdate = vi.fn().mockResolvedValue({
clientId: CLIENT_ID,
name: "My Client v2",
purpose: CLIENT_PURPOSE,
status: "PENDING",
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
rejectionReason: null,
});
const ctx = {
user: {
id: 42,
role: UserPermissionRole.USER,
},
prisma: {
oAuthClient: {
update: prismaUpdate,
},
} as unknown as PrismaClient,
};
await updateClientHandler({
ctx,
input: {
clientId: CLIENT_ID,
name: "My Client v2",
},
});
expect(prismaUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { clientId: CLIENT_ID },
data: {
name: "My Client v2",
status: "PENDING",
rejectionReason: null,
},
})
);
expect(mocks.sendOAuthClientApprovedNotification).not.toHaveBeenCalled();
expect(mocks.sendOAuthClientRejectedNotification).not.toHaveBeenCalled();
});
it("sets status to PENDING when an owner updates redirectUri (reapproval flow)", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "APPROVED",
user: null,
});
const updatedRedirectUri = "https://example.com/new-callback";
const prismaUpdate = vi.fn().mockResolvedValue({
clientId: CLIENT_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
status: "PENDING",
redirectUri: updatedRedirectUri,
websiteUrl: null,
logo: null,
rejectionReason: null,
});
const ctx = {
user: {
id: OWNER_USER_ID,
role: UserPermissionRole.USER,
},
prisma: {
oAuthClient: {
update: prismaUpdate,
},
} as unknown as PrismaClient,
};
await updateClientHandler({
ctx,
input: {
clientId: CLIENT_ID,
redirectUri: updatedRedirectUri,
},
});
expect(prismaUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { clientId: CLIENT_ID },
data: {
redirectUri: updatedRedirectUri,
status: "PENDING",
rejectionReason: null,
},
})
);
});
});