test: Add more orgs tests (#12241)

This commit is contained in:
Hariom Balhara 2023-11-21 22:33:01 +05:30 committed by GitHub
parent 556b382f75
commit 48dde246e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 397 additions and 35 deletions

View File

@ -37,7 +37,7 @@ type SignupProps = inferSSRProps<typeof getServerSideProps>;
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
const [emailUser, emailDomain] = email.split("@");
const [emailUser, emailDomain = ""] = email.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
@ -143,7 +143,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
methods.clearErrors("apiError");
}
if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) {
if (!methods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) {
methods.setValue(
"username",
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail)

View File

@ -0,0 +1,34 @@
import type { Page } from "@playwright/test";
declare global {
interface Window {
E2E_CLIPBOARD_VALUE?: string;
}
}
export type Window = typeof window;
// creates the single server fixture
export const createClipboardFixture = (page: Page) => {
return {
reset: async () => {
await page.evaluate(() => {
delete window.E2E_CLIPBOARD_VALUE;
});
},
get: async () => {
return getClipboardValue({ page });
},
};
};
function getClipboardValue({ page }: { page: Page }) {
return page.evaluate(() => {
return new Promise<string>((resolve, reject) => {
setInterval(() => {
if (!window.E2E_CLIPBOARD_VALUE) return;
resolve(window.E2E_CLIPBOARD_VALUE);
}, 500);
setTimeout(() => reject(new Error("Timeout")), 1000);
});
});
}

View File

@ -86,12 +86,14 @@ const createTeamAndAddUser = async (
user,
isUnpublished,
isOrg,
isOrgVerified,
hasSubteam,
organizationId,
}: {
user: { id: number; username: string | null; role?: MembershipRole };
user: { id: number; email: string; username: string | null; role?: MembershipRole };
isUnpublished?: boolean;
isOrg?: boolean;
isOrgVerified?: boolean;
hasSubteam?: true;
organizationId?: number | null;
},
@ -103,7 +105,14 @@ const createTeamAndAddUser = async (
};
data.metadata = {
...(isUnpublished ? { requestedSlug: slug } : {}),
...(isOrg ? { isOrganization: true } : {}),
...(isOrg
? {
isOrganization: true,
isOrganizationVerified: !!isOrgVerified,
orgAutoAcceptEmail: user.email.split("@")[1],
isOrganizationConfigured: false,
}
: {}),
};
data.slug = !isUnpublished ? slug : undefined;
if (isOrg && hasSubteam) {
@ -145,6 +154,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
teamEventSlug?: string;
teamEventLength?: number;
isOrg?: boolean;
isOrgVerified?: boolean;
hasSubteam?: true;
isUnpublished?: true;
} = {}
@ -292,9 +302,10 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
if (scenario.hasTeam) {
const team = await createTeamAndAddUser(
{
user: { id: user.id, username: user.username, role: "OWNER" },
user: { id: user.id, email: user.email, username: user.username, role: "OWNER" },
isUnpublished: scenario.isUnpublished,
isOrg: scenario.isOrg,
isOrgVerified: scenario.isOrgVerified,
hasSubteam: scenario.hasSubteam,
organizationId: opts?.organizationId,
},
@ -410,6 +421,9 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
routingForms: user.routingForms,
self,
apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page),
/**
* @deprecated use apiLogin instead
*/
login: async () => login({ ...(await self()), password: user.username }, store.page),
logout: async () => {
await page.goto("/auth/logout");

View File

@ -4,10 +4,12 @@ import type { API } from "mailhog";
import mailhog from "mailhog";
import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createClipboardFixture } from "../fixtures/clipboard";
import { createEmbedsFixture } from "../fixtures/embeds";
import { createOrgsFixture } from "../fixtures/orgs";
import { createPaymentsFixture } from "../fixtures/payments";
@ -28,6 +30,7 @@ export interface Fixtures {
emails?: API;
routingForms: ReturnType<typeof createRoutingFormsFixture>;
bookingPage: ReturnType<typeof createBookingPageFixture>;
clipboard: ReturnType<typeof createClipboardFixture>;
}
declare global {
@ -85,6 +88,8 @@ export const test = base.extend<Fixtures>({
const mailhogAPI = mailhog();
await use(mailhogAPI);
} else {
//FIXME: Ideally we should error out here. If someone is running tests with mailhog disabled, they should be aware of it
logger.warn("Mailhog is not enabled - Skipping Emails verification");
await use(undefined);
}
},
@ -92,4 +97,8 @@ export const test = base.extend<Fixtures>({
const bookingPage = createBookingPageFixture(page);
await use(bookingPage);
},
clipboard: async ({ page }, use) => {
const clipboard = createClipboardFixture(page);
await use(clipboard);
},
});

View File

@ -1,11 +1,13 @@
import type { Frame, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { createHash } from "crypto";
import EventEmitter from "events";
import type { IncomingMessage, ServerResponse } from "http";
import { createServer } from "http";
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import type { API, Messages } from "mailhog";
import { totp } from "otplib";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
@ -278,3 +280,12 @@ export async function createUserWithSeatedEventAndAttendees(
});
return { user, eventType, booking };
}
export function generateTotpCode(email: string) {
const secret = createHash("md5")
.update(email + process.env.CALENDSO_ENCRYPTION_KEY)
.digest("hex");
totp.options = { step: 90 };
return totp.generate(secret);
}

View File

@ -0,0 +1,28 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
// eslint-disable-next-line no-restricted-imports
import type { API, Messages } from "mailhog";
import { getEmailsReceivedByUser } from "../lib/testUtils";
export async function expectInvitationEmailToBeReceived(
page: Page,
emails: API | undefined,
userEmail: string,
subject: string,
returnLink?: string
) {
if (!emails) return null;
// We need to wait for the email to go through, otherwise it will fail
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(5000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
expect(receivedEmails?.total).toBe(1);
const [firstReceivedEmail] = (receivedEmails as Messages).items;
expect(firstReceivedEmail.subject).toBe(subject);
if (!returnLink) return;
const dom = new JSDOM(firstReceivedEmail.html);
const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`);
return anchor?.getAttribute("href");
}

View File

@ -0,0 +1,143 @@
import { expect } from "@playwright/test";
import path from "path";
import { test } from "../lib/fixtures";
import { generateTotpCode } from "../lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./expects";
test.afterAll(({ users, emails }) => {
users.deleteAll();
emails?.deleteAll();
});
function capitalize(text: string) {
if (!text) {
return text;
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
test.describe("Organization", () => {
test("should be able to create an organization and complete onboarding", async ({
page,
users,
emails,
}) => {
const orgOwner = await users.create();
const orgDomain = `${orgOwner.username}-org`;
const orgName = capitalize(`${orgOwner.username}-org`);
await orgOwner.apiLogin();
await page.goto("/settings/organizations/new");
await page.waitForLoadState("networkidle");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.locator(".text-red-700")).toHaveCount(3);
// Happy path
await page.locator("input[name=adminEmail]").fill(`john@${orgDomain}.com`);
expect(await page.locator("input[name=name]").inputValue()).toEqual(orgName);
expect(await page.locator("input[name=slug]").inputValue()).toEqual(orgDomain);
await page.locator("button[type=submit]").click();
await page.waitForLoadState("networkidle");
// Check admin email about code verification
await expectInvitationEmailToBeReceived(
page,
emails,
`john@${orgOwner.username}-org.com`,
"Verify your email to create an organization"
);
await test.step("Verification", async () => {
// Code verification
await expect(page.locator("#modal-title")).toBeVisible();
await page.locator("input[name='2fa1']").fill(generateTotpCode(`john@${orgDomain}.com`));
await page.locator("button:text('Verify')").click();
// Check admin email about DNS pending action
await expectInvitationEmailToBeReceived(
page,
emails,
"admin@example.com",
"New organization created: pending action"
);
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/set-password");
});
});
await test.step("Admin password", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.locator(".text-red-700")).toHaveCount(3); // 3 password hints
// Happy path
await page.locator("input[name='password']").fill("ADMIN_user2023$");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/about");
});
await test.step("About the organization", async () => {
// Choosing an avatar
await page.locator('button:text("Upload")').click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Choose a file...").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
await page.locator('button:text("Save")').click();
// About text
await page.locator('textarea[name="about"]').fill("This is a testing org");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/onboard-admins");
});
await test.step("On-board administrators", async () => {
// Required field
await page.locator("button[type=submit]").click();
// Happy path
await page.locator('textarea[name="emails"]').fill(`rick@${orgDomain}.com`);
await page.locator("button[type=submit]").click();
// Check if invited admin received the invitation email
await expectInvitationEmailToBeReceived(
page,
emails,
`rick@${orgDomain}.com`,
`${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
);
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/add-teams");
});
await test.step("Create teams", async () => {
// Initial state
await expect(page.locator('input[name="teams.0.name"]')).toHaveCount(1);
await expect(page.locator('button:text("Continue")')).toBeDisabled();
// Filling one team
await page.locator('input[name="teams.0.name"]').fill("Marketing");
await expect(page.locator('button:text("Continue")')).toBeEnabled();
// Adding another team
await page.locator('button:text("Add a team")').click();
await expect(page.locator('button:text("Continue")')).toBeDisabled();
await expect(page.locator('input[name="teams.1.name"]')).toHaveCount(1);
await page.locator('input[name="teams.1.name"]').fill("Sales");
await expect(page.locator('button:text("Continue")')).toBeEnabled();
// Finishing the creation wizard
await page.locator('button:text("Continue")').click();
await page.waitForURL("/event-types");
});
});
});

View File

@ -0,0 +1,119 @@
import { expect } from "@playwright/test";
import { test } from "../lib/fixtures";
import { expectInvitationEmailToBeReceived } from "./expects";
test.describe.configure({ mode: "parallel" });
test.afterEach(async ({ users, emails, clipboard }) => {
clipboard.reset();
await users.deleteAll();
emails?.deleteAll();
});
test.describe("Organization", () => {
test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => {
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true });
const { team: org } = await orgOwner.getOrg();
await orgOwner.apiLogin();
await page.goto("/settings/organizations/members");
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (external user)", async () => {
const invitedUserEmail = `rick@domain-${Date.now()}.com`;
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
await page.waitForLoadState("networkidle");
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
"signup?token"
);
// Check newly invited member exists and is pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!inviteLink) return null;
// Follow invite link in new window
const context = await browser.newContext();
const newPage = await context.newPage();
newPage.goto(inviteLink);
await newPage.waitForLoadState("networkidle");
// Check required fields
await newPage.locator("button[type=submit]").click();
await expect(newPage.locator(".text-red-700")).toHaveCount(3); // 3 password hints
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await newPage.locator("button[type=submit]").click();
await newPage.waitForURL("/getting-started?from=signup");
await context.close();
await newPage.close();
// Check newly invited member is not pending anymore
await page.bringToFront();
await page.goto("/settings/organizations/members");
page.locator(`[data-testid="login-form"]`);
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
});
await test.step("To the organization by invite link", async () => {
// Get the invite link
await page.locator('button:text("Add")').click();
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
const inviteLink = await clipboard.get();
await page.waitForLoadState("networkidle");
// Follow invite link in new window
const context = await browser.newContext();
const inviteLinkPage = await context.newPage();
await inviteLinkPage.goto(inviteLink);
await inviteLinkPage.waitForLoadState("networkidle");
// Check required fields
await inviteLinkPage.locator("button[type=submit]").click();
await expect(inviteLinkPage.locator(".text-red-700")).toHaveCount(4); // email + 3 password hints
// Happy path
await inviteLinkPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`);
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await inviteLinkPage.locator("button[type=submit]").click();
await inviteLinkPage.waitForURL("/getting-started");
});
});
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true });
const { team: org } = await orgOwner.getOrg();
await orgOwner.apiLogin();
await page.goto("/settings/organizations/members");
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (internal user)", async () => {
const invitedUserEmail = `rick@example.com`;
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
await page.waitForLoadState("networkidle");
await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`
);
// Check newly invited member exists and is pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
});
});
});

View File

@ -82,6 +82,7 @@
"@playwright/test": "^1.31.2",
"@snaplet/copycat": "^0.3.0",
"@testing-library/jest-dom": "^5.16.5",
"@types/jsdom": "^21.1.3",
"@types/jsonwebtoken": "^9.0.3",
"c8": "^7.13.0",
"checkly": "latest",

View File

@ -11,24 +11,6 @@ const MembersView = () => {
<LicenseRequired>
<Meta title={t("organization_members")} description={t("organization_description")} />
<div>
{/* {team && (
<>
{isInviteOpen && (
<TeamInviteList
teams={[
{
id: team.id,
accepted: team.membership.accepted || false,
logo: team.logo,
name: team.name,
slug: team.slug,
role: team.membership.role,
},
]}
/>
)}
</>
)} */}
<UserListTable />
</div>
</LicenseRequired>

View File

@ -25,6 +25,7 @@ import {
TextAreaField,
} from "@calcom/ui";
import { Link } from "@calcom/ui/components/icon";
import type { Window as WindowWithClipboardValue } from "@calcom/web/playwright/fixtures/clipboard";
import type { PendingMember } from "../lib/types";
import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton";
@ -92,8 +93,15 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
const inviteLink =
isOrgInvite || (props?.orgMembers && props.orgMembers?.length > 0) ? orgInviteLink : teamInviteLink;
await navigator.clipboard.writeText(inviteLink);
showToast(t("invite_link_copied"), "success");
try {
await navigator.clipboard.writeText(inviteLink);
showToast(t("invite_link_copied"), "success");
} catch (e) {
if (process.env.NEXT_PUBLIC_IS_E2E) {
(window as WindowWithClipboardValue).E2E_CLIPBOARD_VALUE = inviteLink;
}
console.error(e);
}
};
const options: MembershipRoleOption[] = useMemo(() => {

View File

@ -204,12 +204,15 @@ export function UserListTable() {
id: "teams",
header: "Teams",
cell: ({ row }) => {
const { teams, accepted } = row.original;
const { teams, accepted, email } = row.original;
// TODO: Implement click to filter
return (
<div className="flex h-full flex-wrap items-center gap-2">
{accepted ? null : (
<Badge variant="red" className="text-xs">
<Badge
variant="red"
className="text-xs"
data-testid={`email-${email.replace("@", "")}-pending`}>
Pending
</Badge>
)}

View File

@ -7,12 +7,7 @@ import { sendAdminOrganizationNotification } from "@calcom/emails";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import {
IS_TEAM_BILLING_ENABLED,
RESERVED_SUBDOMAINS,
IS_PRODUCTION,
WEBAPP_URL,
} from "@calcom/lib/constants";
import { IS_TEAM_BILLING_ENABLED, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
@ -175,7 +170,6 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
return { user: { ...createOwnerOrg, password } };
} else {
if (!IS_PRODUCTION) return { checked: true };
const language = await getTranslation(input.language ?? "en", "common");
const secret = createHash("md5")

View File

@ -2,6 +2,7 @@ import { createHash } from "crypto";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { totpRawCheck } from "@calcom/lib/totp";
import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@ -21,7 +22,10 @@ export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => {
if (!user || !email || !code) throw new TRPCError({ code: "BAD_REQUEST" });
if (!IS_PRODUCTION) return true;
if (!IS_PRODUCTION || process.env.NEXT_PUBLIC_IS_E2E) {
logger.warn(`Skipping code verification in dev/E2E environment`);
return true;
}
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: email,

View File

@ -13331,6 +13331,17 @@ __metadata:
languageName: node
linkType: hard
"@types/jsdom@npm:^21.1.3":
version: 21.1.4
resolution: "@types/jsdom@npm:21.1.4"
dependencies:
"@types/node": "*"
"@types/tough-cookie": "*"
parse5: ^7.0.0
checksum: 915f619111dadd8d1bb7f12b6736c9d2e486911e1aed086de5fb003e7e40ae1e368da322dc04f2122ef47faf40ca75b9315ae2df3e8011f882dcf84660fb0d68
languageName: node
linkType: hard
"@types/jsforce@npm:^1.11.0":
version: 1.11.0
resolution: "@types/jsforce@npm:1.11.0"
@ -17197,6 +17208,7 @@ __metadata:
"@playwright/test": ^1.31.2
"@snaplet/copycat": ^0.3.0
"@testing-library/jest-dom": ^5.16.5
"@types/jsdom": ^21.1.3
"@types/jsonwebtoken": ^9.0.3
c8: ^7.13.0
checkly: latest