test: E2E for Orgs - The beginning (#12095)

This commit is contained in:
Hariom Balhara 2023-10-27 18:14:16 +05:30 committed by GitHub
parent 09ecd445bb
commit 426d31712e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 408 additions and 64 deletions

View File

@ -3,7 +3,10 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import {
getOrgDomainConfigFromHostname,
subdomainSuffix,
} from "@calcom/features/ee/organizations/lib/orgDomains";
import { DOCS_URL, IS_CALCOM, JOIN_DISCORD, WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HeadSeo } from "@calcom/ui";
@ -50,7 +53,10 @@ export default function Custom404() {
const [url, setUrl] = useState(`${WEBSITE_URL}/signup`);
useEffect(() => {
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host);
const { isValidOrgDomain, currentOrgDomain } = getOrgDomainConfigFromHostname({
hostname: window.location.host,
});
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/) ?? [];
if (routerUsername && (!isValidOrgDomain || !currentOrgDomain)) {
const splitPath = routerUsername.split("/");

View File

@ -275,10 +275,7 @@ export type UserPageProps = {
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
context.req.headers.host ?? "",
context.params?.orgSlug
);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const usernameList = getUsernameList(context.query.user as string);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();

View File

@ -72,10 +72,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
context.req.headers.host ?? "",
context.params?.orgSlug
);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const users = await prisma.user.findMany({
where: {
@ -148,10 +145,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const username = usernames[0];
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
context.req.headers.host ?? "",
context.params?.orgSlug
);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain;

View File

@ -154,7 +154,7 @@ async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) {
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { query } = req;
const parsedQuery = logoApiSchema.parse(query);
const { isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
const { isValidOrgDomain } = orgDomainConfig(req);
const hostname = req?.headers["host"];
if (!hostname) throw new Error("No hostname");

View File

@ -29,7 +29,7 @@ const querySchema = z
async function getIdentityData(req: NextApiRequest) {
const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req);
const org = isValidOrgDomain ? currentOrgDomain : null;

View File

@ -9,7 +9,7 @@ type Response = {
};
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
const { currentOrgDomain } = orgDomainConfig(req.headers.host ?? "");
const { currentOrgDomain } = orgDomainConfig(req);
const result = await checkUsername(req.body.username, currentOrgDomain);
return res.status(200).json(result);
}

View File

@ -65,7 +65,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const session = await getServerSession({ req, res });
const ssr = await ssrInit(context);
const { currentOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const { currentOrgDomain } = orgDomainConfig(context.req);
if (session) {
// Validating if username is Premium, while this is true an email its required for stripe user confirmation

View File

@ -61,7 +61,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
const { link, slug } = paramsSchema.parse(context.params);
const { rescheduleUid, duration: queryDuration } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
const org = isValidOrgDomain ? currentOrgDomain : null;
const { ssrInit } = await import("@server/lib/ssr");

View File

@ -269,10 +269,7 @@ function TeamPage({
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
context.req.headers.host ?? "",
context.params?.orgSlug
);
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
// Provided by Rewrite from next.config.js

View File

@ -74,10 +74,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { rescheduleUid, duration: queryDuration } = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
context.req.headers.host ?? "",
context.params?.orgSlug
);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {

View File

@ -0,0 +1,56 @@
import type { Page } from "@playwright/test";
import type { Team } from "@prisma/client";
import { prisma } from "@calcom/prisma";
const getRandomSlug = () => `org-${Math.random().toString(36).substring(7)}`;
// creates a user fixture instance and stores the collection
export const createOrgsFixture = (page: Page) => {
const store = { orgs: [], page } as { orgs: Team[]; page: typeof page };
return {
create: async (opts: { name: string; slug?: string; requestedSlug?: string }) => {
const org = await createOrgInDb({
name: opts.name,
slug: opts.slug || getRandomSlug(),
requestedSlug: opts.requestedSlug,
});
store.orgs.push(org);
return org;
},
get: () => store.orgs,
deleteAll: async () => {
await prisma.team.deleteMany({ where: { id: { in: store.orgs.map((org) => org.id) } } });
store.orgs = [];
},
delete: async (id: number) => {
await prisma.team.delete({ where: { id } });
store.orgs = store.orgs.filter((b) => b.id !== id);
},
};
};
async function createOrgInDb({
name,
slug,
requestedSlug,
}: {
name: string;
slug: string | null;
requestedSlug?: string;
}) {
return await prisma.team.create({
data: {
name: name,
slug: slug,
metadata: {
isOrganization: true,
...(requestedSlug
? {
requestedSlug,
}
: null),
},
},
});
}

View File

@ -9,6 +9,7 @@ import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/avail
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
import { TimeZoneEnum } from "./types";
@ -78,11 +79,13 @@ const createTeamAndAddUser = async (
isUnpublished,
isOrg,
hasSubteam,
organizationId,
}: {
user: { id: number; username: string | null; role?: MembershipRole };
isUnpublished?: boolean;
isOrg?: boolean;
hasSubteam?: true;
organizationId?: number | null;
},
workerInfo: WorkerInfo
) => {
@ -101,6 +104,7 @@ const createTeamAndAddUser = async (
data.children = { connect: [{ id: team.id }] };
}
data.orgUsers = isOrg ? { connect: [{ id: user.id }] } : undefined;
data.parent = organizationId ? { connect: { id: organizationId } } : undefined;
const team = await prisma.team.create({
data,
});
@ -114,6 +118,7 @@ const createTeamAndAddUser = async (
accepted: true,
},
});
return team;
};
@ -282,6 +287,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
isUnpublished: scenario.isUnpublished,
isOrg: scenario.isOrg,
hasSubteam: scenario.hasSubteam,
organizationId: opts?.organizationId,
},
workerInfo
);
@ -399,11 +405,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
logout: async () => {
await page.goto("/auth/logout");
},
getTeam: async () => {
return prisma.membership.findFirstOrThrow({
getFirstTeam: async () => {
const memberships = await prisma.membership.findMany({
where: { userId: user.id },
include: { team: true },
});
const membership = memberships
.map((membership) => {
return {
...membership,
team: {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
},
};
})
.find((membership) => !membership.team?.metadata?.isOrganization);
if (!membership) {
throw new Error("No team found for user");
}
return membership;
},
getOrg: async () => {
return prisma.membership.findFirstOrThrow({
@ -453,16 +475,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
_bookings?: PrismaType.BookingCreateInput[];
};
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name" | "email";
type CustomUserOptsKeys =
| "username"
| "password"
| "completedOnboarding"
| "locale"
| "name"
| "email"
| "organizationId";
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
timeZone?: TimeZoneEnum;
eventTypes?: SupportedTestEventTypes[];
// ignores adding the worker-index after username
useExactUsername?: boolean;
roleInOrganization?: MembershipRole;
};
// creates the actual user in the db.
const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => {
const createUser = (
workerInfo: WorkerInfo,
opts?: CustomUserOpts | null
): PrismaType.UserUncheckedCreateInput => {
// build a unique name for our user
const uname =
opts?.useExactUsername && opts?.username
@ -478,6 +511,7 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
completedOnboarding: opts?.completedOnboarding ?? true,
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
locale: opts?.locale ?? "en",
...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }),
schedules:
opts?.completedOnboarding ?? true
? {
@ -493,6 +527,42 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
}
: undefined,
};
function getOrganizationRelatedProps({
organizationId,
role,
}: {
organizationId: number | null | undefined;
role: MembershipRole | undefined;
}) {
if (!organizationId) {
return null;
}
if (!role) {
throw new Error("Missing role for user in organization");
}
return {
organizationId: organizationId || null,
...(organizationId
? {
teams: {
// Create membership
create: [
{
team: {
connect: {
id: organizationId,
},
},
accepted: true,
role: MembershipRole.ADMIN,
},
],
},
}
: null),
};
}
};
async function confirmPendingPayment(page: Page) {

View File

@ -9,6 +9,7 @@ import prisma from "@calcom/prisma";
import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createEmbedsFixture } from "../fixtures/embeds";
import { createOrgsFixture } from "../fixtures/orgs";
import { createPaymentsFixture } from "../fixtures/payments";
import { createBookingPageFixture } from "../fixtures/regularBookings";
import { createRoutingFormsFixture } from "../fixtures/routingForms";
@ -17,6 +18,7 @@ import { createUsersFixture } from "../fixtures/users";
export interface Fixtures {
page: Page;
orgs: ReturnType<typeof createOrgsFixture>;
users: ReturnType<typeof createUsersFixture>;
bookings: ReturnType<typeof createBookingsFixture>;
payments: ReturnType<typeof createPaymentsFixture>;
@ -48,6 +50,10 @@ declare global {
* @see https://playwright.dev/docs/test-fixtures
*/
export const test = base.extend<Fixtures>({
orgs: async ({ page }, use) => {
const orgsFixture = createOrgsFixture(page);
await use(orgsFixture);
},
users: async ({ page, context, emails }, use, workerInfo) => {
const usersFixture = createUsersFixture(page, emails, workerInfo);
await use(usersFixture);

View File

@ -1,16 +1,16 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { prisma } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { test } from "./lib/fixtures";
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth, testName, todo } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Teams", () => {
test.describe("Teams - NonOrg", () => {
test.afterEach(({ users }) => users.deleteAll());
test("Can create teams via Wizard", async ({ page, users }) => {
const user = await users.create();
const inviteeEmail = `${user.username}+invitee@example.com`;
@ -64,6 +64,7 @@ test.describe("Teams", () => {
// await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
});
});
test("Can create a booking for Collective EventType", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
@ -78,7 +79,7 @@ test.describe("Teams", () => {
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
});
const { team } = await owner.getTeam();
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
@ -99,6 +100,7 @@ test.describe("Teams", () => {
// TODO: Assert whether the user received an email
});
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
@ -113,7 +115,7 @@ test.describe("Teams", () => {
schedulingType: SchedulingType.ROUND_ROBIN,
});
const { team } = await owner.getTeam();
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
@ -135,6 +137,7 @@ test.describe("Teams", () => {
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
test("Non admin team members cannot create team in org", async ({ page, users }) => {
const teamMateName = "teammate-1";
@ -169,6 +172,7 @@ test.describe("Teams", () => {
await prisma.team.delete({ where: { id: org.teamId } });
}
});
test("Can create team with same name as user", async ({ page, users }) => {
// Name to be used for both user and team
const uniqueName = "test-unique-name";
@ -210,6 +214,7 @@ test.describe("Teams", () => {
await prisma.team.delete({ where: { id: team?.id } });
});
});
test("Can create a private team", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
@ -226,7 +231,7 @@ test.describe("Teams", () => {
});
await owner.apiLogin();
const { team } = await owner.getTeam();
const { team } = await owner.getFirstTeam();
// Mark team as private
await page.goto(`/settings/teams/${team.id}/members`);
@ -247,3 +252,180 @@ test.describe("Teams", () => {
todo("Reschedule a Collective EventType booking");
todo("Reschedule a Round Robin EventType booking");
});
test.describe("Teams - Org", () => {
test.afterEach(({ orgs, users }) => {
orgs.deleteAll();
users.deleteAll();
});
test("Can create teams via Wizard", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const user = await users.create({
organizationId: org.id,
roleInOrganization: MembershipRole.ADMIN,
});
const inviteeEmail = `${user.username}+invitee@example.com`;
await user.apiLogin();
await page.goto("/teams");
await test.step("Can create team", async () => {
// Click text=Create Team
await page.locator("text=Create a new Team").click();
await page.waitForURL((url) => url.pathname === "/settings/teams/new");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
// Click text=Continue
await page.locator("text=Continue").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
await page.waitForSelector('[data-testid="pending-member-list"]');
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
});
await test.step("Can add members", async () => {
// Click [data-testid="new-member-button"]
await page.locator('[data-testid="new-member-button"]').click();
// Fill [placeholder="email\@example\.com"]
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
// Click [data-testid="invite-new-member-button"]
await page.locator('[data-testid="invite-new-member-button"]').click();
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
});
await test.step("Can remove members", async () => {
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
const lastRemoveMemberButton = page.locator('[data-testid="remove-member-button"]').last();
await lastRemoveMemberButton.click();
await page.waitForLoadState("networkidle");
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
// Cleanup here since this user is created without our fixtures.
await prisma.user.delete({ where: { email: inviteeEmail } });
});
await test.step("Can finish team creation", async () => {
await page.locator("text=Finish").click();
await page.waitForURL("/settings/teams");
});
await test.step("Can disband team", async () => {
await page.locator('[data-testid="team-list-item-link"]').click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
await page.locator("text=Disband Team").click();
await page.locator("text=Yes, disband team").click();
await page.waitForURL("/teams");
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
});
});
test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// The title of the booking
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
// The booker should be in the attendee list
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
// All the teammates should be in the booking
for (const teammate of teamMatesObj) {
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
}
}
);
// TODO: Assert whether the user received an email
});
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(ownerObj, {
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
});
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// The person who booked the meeting should be in the attendee list
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
// The title of the booking
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
// Since all the users have the same leastRecentlyBooked value
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
});
async function doOnOrgDomain(
{ orgSlug, page }: { orgSlug: string | null; page: Page },
callback: ({ page }: { page: Page }) => Promise<void>
) {
if (!orgSlug) {
throw new Error("orgSlug is not available");
}
page.setExtraHTTPHeaders({
"x-cal-force-slug": orgSlug,
});
await callback({ page });
}

View File

@ -18,7 +18,7 @@ test.afterAll(async ({ users }) => {
test.describe("Unpublished", () => {
test("Regular team profile", async ({ page, users }) => {
const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true });
const { team } = await owner.getTeam();
const { team } = await owner.getFirstTeam();
const { requestedSlug } = team.metadata as { requestedSlug: string };
await page.goto(`/team/${requestedSlug}`);
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
@ -33,7 +33,7 @@ test.describe("Unpublished", () => {
isUnpublished: true,
schedulingType: SchedulingType.COLLECTIVE,
});
const { team } = await owner.getTeam();
const { team } = await owner.getFirstTeam();
const { requestedSlug } = team.metadata as { requestedSlug: string };
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${requestedSlug}/${teamEventSlug}`);

View File

@ -54,7 +54,7 @@ export const getServerSideProps = async function getServerSideProps(
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {

View File

@ -248,7 +248,7 @@ export const getServerSideProps = async function getServerSideProps(
notFound: true,
};
}
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
const isEmbed = params.appPages[1] === "embed";

View File

@ -1,16 +1,33 @@
import type { Prisma } from "@prisma/client";
import type { IncomingMessage } from "http";
import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import slugify from "@calcom/lib/slugify";
const log = logger.getSubLogger({
prefix: ["orgDomains.ts"],
});
/**
* return the org slug
* @param hostname
*/
export function getOrgSlug(hostname: string) {
export function getOrgSlug(hostname: string, forcedSlug?: string) {
if (forcedSlug) {
if (process.env.NEXT_PUBLIC_IS_E2E) {
log.debug("Using provided forcedSlug in E2E", {
forcedSlug,
});
return forcedSlug;
}
log.debug("Ignoring forcedSlug in non-test mode", {
forcedSlug,
});
}
if (!hostname.includes(".")) {
// A no-dot domain can never be org domain. It automatically handles localhost
log.warn('Org support not enabled for hostname without "."', { hostname });
// A no-dot domain can never be org domain. It automatically considers localhost to be non-org domain
return null;
}
// Find which hostname is being currently used
@ -19,24 +36,45 @@ export function getOrgSlug(hostname: string) {
const testHostname = `${url.hostname}${url.port ? `:${url.port}` : ""}`;
return testHostname.endsWith(`.${ahn}`);
});
logger.debug(`getOrgSlug: ${hostname} ${currentHostname}`, {
ALLOWED_HOSTNAMES,
WEBAPP_URL,
currentHostname,
hostname,
});
if (currentHostname) {
// Define which is the current domain/subdomain
const slug = hostname.replace(`.${currentHostname}` ?? "", "");
return slug.indexOf(".") === -1 ? slug : null;
if (!currentHostname) {
log.warn("Match of WEBAPP_URL with ALLOWED_HOSTNAME failed", { WEBAPP_URL, ALLOWED_HOSTNAMES });
return null;
}
// Define which is the current domain/subdomain
const slug = hostname.replace(`.${currentHostname}` ?? "", "");
const hasNoDotInSlug = slug.indexOf(".") === -1;
if (hasNoDotInSlug) {
return slug;
}
log.warn("Derived slug ended up having dots, so not considering it an org domain", { slug });
return null;
}
export function orgDomainConfig(hostname: string, fallback?: string | string[]) {
const currentOrgDomain = getOrgSlug(hostname);
export function orgDomainConfig(req: IncomingMessage | undefined, fallback?: string | string[]) {
const forcedSlugHeader = req?.headers?.["x-cal-force-slug"];
const forcedSlug = forcedSlugHeader instanceof Array ? forcedSlugHeader[0] : forcedSlugHeader;
const hostname = req?.headers?.host || "";
return getOrgDomainConfigFromHostname({
hostname,
fallback,
forcedSlug,
});
}
export function getOrgDomainConfigFromHostname({
hostname,
fallback,
forcedSlug,
}: {
hostname: string;
fallback?: string | string[];
forcedSlug?: string;
}) {
const currentOrgDomain = getOrgSlug(hostname, forcedSlug);
const isValidOrgDomain = currentOrgDomain !== null && !RESERVED_SUBDOMAINS.includes(currentOrgDomain);
logger.debug(`orgDomainConfig: ${hostname} ${currentOrgDomain} ${isValidOrgDomain}`);
if (isValidOrgDomain || !fallback) {
return {
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
@ -100,6 +138,6 @@ export function whereClauseForOrgWithSlugOrRequestedSlug(slug: string) {
}
export function userOrgQuery(hostname: string, fallback?: string | string[]) {
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(hostname, fallback);
const { currentOrgDomain, isValidOrgDomain } = getOrgDomainConfigFromHostname({ hostname, fallback });
return isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null;
}

View File

@ -183,6 +183,7 @@ export default function TeamListItem(props: Props) {
<div className={classNames("flex items-center justify-between", !isInvitee && "hover:bg-muted group")}>
{!isInvitee ? (
<Link
data-testid="team-list-item-link"
href={`/settings/teams/${team.id}/profile`}
className="flex-grow cursor-pointer truncate text-sm"
title={`${team.name}`}>

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { orgDomainConfig, getOrgSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrgSlug, getOrgDomainConfigFromHostname } from "@calcom/features/ee/organizations/lib/orgDomains";
import * as constants from "@calcom/lib/constants";
function setupEnvs({ WEBAPP_URL = "https://app.cal.com" } = {}) {
@ -35,10 +35,10 @@ function setupEnvs({ WEBAPP_URL = "https://app.cal.com" } = {}) {
}
describe("Org Domains Utils", () => {
describe("orgDomainConfig", () => {
describe("getOrgDomainConfigFromHostname", () => {
it("should return a valid org domain", () => {
setupEnvs();
expect(orgDomainConfig("acme.cal.com")).toEqual({
expect(getOrgDomainConfigFromHostname({ hostname: "acme.cal.com" })).toEqual({
currentOrgDomain: "acme",
isValidOrgDomain: true,
});
@ -46,7 +46,7 @@ describe("Org Domains Utils", () => {
it("should return a non valid org domain", () => {
setupEnvs();
expect(orgDomainConfig("app.cal.com")).toEqual({
expect(getOrgDomainConfigFromHostname({ hostname: "app.cal.com" })).toEqual({
currentOrgDomain: null,
isValidOrgDomain: false,
});
@ -54,7 +54,7 @@ describe("Org Domains Utils", () => {
it("should return a non valid org domain for localhost", () => {
setupEnvs();
expect(orgDomainConfig("localhost:3000")).toEqual({
expect(getOrgDomainConfigFromHostname({ hostname: "localhost:3000" })).toEqual({
currentOrgDomain: null,
isValidOrgDomain: false,
});

View File

@ -266,7 +266,7 @@ export function getRegularOrDynamicEventType(
}
export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
const orgDetails = orgDomainConfig(ctx?.req?.headers.host ?? "");
const orgDetails = orgDomainConfig(ctx?.req);
if (process.env.INTEGRATION_TEST_MODE === "true") {
logger.settings.minLevel = 2;
}