Files
calendar/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.integration-test.ts
2025-08-14 01:48:09 +00:00

439 lines
13 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { prisma } from "@calcom/prisma";
import type { Team, User, Membership, Profile } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import inviteMemberHandler, { inviteMembersWithNoInviterPermissionCheck } from "./inviteMember.handler";
// Helper functions for database verification
async function verifyProfileExists(userId: number, organizationId: number): Promise<Profile | null> {
return await prisma.profile.findUnique({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
});
}
async function verifyMembershipExists(userId: number, teamId: number): Promise<Membership | null> {
return await prisma.membership.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
}
async function verifyUserOrganizationConsistency(userId: number): Promise<{
profileCount: number;
acceptedMembershipCount: number;
pendingMembershipCount: number;
}> {
const profiles = await prisma.profile.count({ where: { userId } });
const acceptedMemberships = await prisma.membership.count({
where: { userId, accepted: true },
});
const pendingMemberships = await prisma.membership.count({
where: { userId, accepted: false },
});
return {
profileCount: profiles,
acceptedMembershipCount: acceptedMemberships,
pendingMembershipCount: pendingMemberships,
};
}
// Test data creation helpers with unique identifiers
function generateUniqueId() {
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
}
async function createTestUser(data: { email: string; username: string; name?: string }): Promise<User> {
const uniqueId = generateUniqueId();
const uniqueEmail = data.email.includes("@")
? data.email.replace("@", `-${uniqueId}@`)
: `${data.email}-${uniqueId}@example.com`;
const uniqueUsername = `${data.username}-${uniqueId}`;
// Pre-emptive cleanup to avoid conflicts
try {
await prisma.user.deleteMany({
where: { OR: [{ email: uniqueEmail }, { username: uniqueUsername }] },
});
} catch {}
return await prisma.user.create({
data: {
email: uniqueEmail,
username: uniqueUsername,
name: data.name || uniqueUsername,
},
});
}
async function createTestTeam(data: {
name: string;
slug: string;
isOrganization?: boolean;
parentId?: number;
metadata?: any;
organizationSettings?: {
orgAutoAcceptEmail: string;
isOrganizationVerified?: boolean;
};
}): Promise<Team> {
const uniqueId = generateUniqueId();
const uniqueSlug = `${data.slug}-${uniqueId}`;
// Pre-emptive cleanup to avoid conflicts
try {
await prisma.team.deleteMany({
where: { slug: uniqueSlug },
});
} catch {
// Ignore cleanup errors
}
const team = await prisma.team.create({
data: {
name: `${data.name} ${uniqueId}`,
slug: uniqueSlug,
isOrganization: data.isOrganization || false,
parentId: data.parentId,
metadata: data.metadata,
},
});
// If this is an organization and organizationSettings are provided, create them
if (data.isOrganization && data.organizationSettings) {
await prisma.organizationSettings.create({
data: {
organizationId: team.id,
orgAutoAcceptEmail: data.organizationSettings.orgAutoAcceptEmail,
isOrganizationVerified: data.organizationSettings.isOrganizationVerified,
},
});
}
return team;
}
// Mock translations fetch
vi.mock("node-fetch", () => ({
default: vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
),
}));
// Mock email sending
vi.mock("@calcom/emails", () => ({
sendTeamInviteEmail: vi.fn(() => Promise.resolve()),
sendOrganizationAutoJoinEmail: vi.fn(() => Promise.resolve()),
}));
// Mock for getTranslation
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: vi.fn(() => Promise.resolve((key: string) => key)),
}));
describe("inviteMember.handler Integration Tests", () => {
// Track created test data for cleanup
let testUsers: User[] = [];
let testTeams: Team[] = [];
beforeEach(async () => {
// Reset tracking arrays
testUsers = [];
testTeams = [];
});
afterEach(async () => {
// Clean up test data in reverse dependency order
const userIds = testUsers.map((u) => u.id);
const teamIds = testTeams.map((t) => t.id);
if (userIds.length > 0 || teamIds.length > 0) {
await prisma.$transaction([
// Clean verification tokens for test users
prisma.verificationToken.deleteMany({
where: { identifier: { in: testUsers.map((u) => u.email) } },
}),
// Clean memberships
prisma.membership.deleteMany({
where: {
OR: [{ userId: { in: userIds } }, { teamId: { in: teamIds } }],
},
}),
// Clean profiles
prisma.profile.deleteMany({
where: {
OR: [{ userId: { in: userIds } }, { organizationId: { in: teamIds } }],
},
}),
// Clean teams
prisma.team.deleteMany({
where: { id: { in: teamIds } },
}),
// Clean users
prisma.user.deleteMany({
where: { id: { in: userIds } },
}),
]);
}
});
// Helper to track created entities
function trackUser(user: User): User {
testUsers.push(user);
return user;
}
function trackTeam(team: Team): Team {
testTeams.push(team);
return team;
}
// Helper to create a proper user context for inviteMemberHandler
function createUserContext(user: User, organizationId: number | null = null): NonNullable<TrpcSessionUser> {
const baseUser = {
id: user.id,
email: user.email,
username: user.username || "",
name: user.name,
emailVerified: user.emailVerified,
avatarUrl: user.avatarUrl,
avatar: `https://example.com/avatar.png`,
locale: user.locale || "en",
timeZone: user.timeZone || "UTC",
role: user.role,
allowDynamicBooking: user.allowDynamicBooking || true,
completedOnboarding: user.completedOnboarding,
twoFactorEnabled: user.twoFactorEnabled,
identityProvider: user.identityProvider,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
theme: user.theme,
createdDate: user.createdDate,
disableImpersonation: user.disableImpersonation,
movedToProfileId: user.movedToProfileId,
organizationId,
organization: organizationId
? {
id: organizationId,
isOrgAdmin: true,
metadata: {},
requestedSlug: null,
}
: {
id: null,
isOrgAdmin: false,
metadata: {},
requestedSlug: null,
},
defaultBookerLayouts: null,
profile: organizationId
? {
id: user.id,
upId: `${user.username}-${organizationId}`,
username: user.username || "",
userId: user.id,
organizationId,
organization: {
id: organizationId,
name: "Test Organization",
slug: "test-org",
isOrganization: true,
metadata: {},
},
}
: {
id: user.id,
upId: `${user.username}-default`,
username: user.username || "",
userId: user.id,
organizationId: null,
organization: null,
},
};
return baseUser as unknown as NonNullable<TrpcSessionUser>;
}
describe("Organization Direct Invite Flow", () => {
it("should not auto-accept user's membership that was unaccepted when migrating to org with non-matching autoAcceptEmailDomain", async () => {
const organization = trackTeam(
await createTestTeam({
name: "Test Organization",
slug: "test-org",
isOrganization: true,
metadata: {},
organizationSettings: {
orgAutoAcceptEmail: "company.com",
isOrganizationVerified: true,
},
})
);
const team = trackTeam(
await createTestTeam({
name: "Test Team",
slug: "test-team",
isOrganization: false,
parentId: organization.id,
})
);
const inviterUser = trackUser(
await createTestUser({
email: "[email protected]",
username: "inviter",
})
);
await prisma.membership.create({
data: {
userId: inviterUser.id,
teamId: organization.id,
role: MembershipRole.OWNER,
accepted: true,
},
});
// Act: Invite user with non-matching domain
const nonMatchingUser = trackUser(
await createTestUser({
email: "[email protected]",
username: "external",
})
);
await prisma.membership.create({
data: {
userId: nonMatchingUser.id,
teamId: team.id,
role: MembershipRole.MEMBER,
accepted: false,
},
});
await inviteMemberHandler({
ctx: {
user: createUserContext(inviterUser, organization.id),
session: {} as any,
} as any,
input: {
teamId: organization.id,
usernameOrEmail: nonMatchingUser.email,
role: MembershipRole.MEMBER,
language: "en",
creationSource: "WEBAPP" as const,
},
});
// Assert: No profile should be created
const profile = await verifyProfileExists(nonMatchingUser.id, organization.id);
expect(profile).toBeFalsy();
// Membership should not be auto-accepted
const membership = await verifyMembershipExists(nonMatchingUser.id, organization.id);
expect(membership).toBeTruthy();
expect(membership?.accepted).toBe(false);
// Verify that the user is not a member of the organization
const nonMatchingUserMembershipWithTeam = await verifyMembershipExists(nonMatchingUser.id, team.id);
expect(nonMatchingUserMembershipWithTeam).toBeTruthy();
expect(nonMatchingUserMembershipWithTeam?.accepted).toBe(false);
});
it("should auto-accept user's membership that was unaccepted when migrating to org with matching autoAcceptEmailDomain", async () => {
// Setup: User with unverified email who has an unaccepted team membership
const unverifiedUserWithUnacceptedMembership = trackUser(
await createTestUser({
email: "[email protected]",
username: "john.doe",
})
);
// Add unverified email status - will need to update in DB
await prisma.user.update({
where: { id: unverifiedUserWithUnacceptedMembership.id },
data: { emailVerified: null },
});
const organization = trackTeam(
await createTestTeam({
name: "Acme",
slug: "acme",
isOrganization: true,
metadata: {},
organizationSettings: {
orgAutoAcceptEmail: "acme.com", // Matches user's email domain
isOrganizationVerified: true,
},
})
);
// Create a regular team first where user has unaccepted membership
const regularTeam = trackTeam(
await createTestTeam({
name: "Regular Team",
slug: "regular-team",
isOrganization: false,
parentId: organization.id,
})
);
// Add unaccepted team membership
await prisma.membership.create({
data: {
teamId: regularTeam.id,
userId: unverifiedUserWithUnacceptedMembership.id,
accepted: false, // KEY: Unaccepted membership
role: MembershipRole.MEMBER,
},
});
// Act: Simulate migration by inviting the user to the organization
await inviteMembersWithNoInviterPermissionCheck({
inviterName: null,
teamId: organization.id,
language: "en",
creationSource: "WEBAPP" as const,
orgSlug: organization.slug,
invitations: [
{
usernameOrEmail: unverifiedUserWithUnacceptedMembership.email,
role: MembershipRole.MEMBER,
},
],
});
const orgMembership = await verifyMembershipExists(
unverifiedUserWithUnacceptedMembership.id,
organization.id
);
const profile = await verifyProfileExists(unverifiedUserWithUnacceptedMembership.id, organization.id);
expect(profile?.userId).toBe(unverifiedUserWithUnacceptedMembership.id);
expect(profile?.organizationId).toBe(organization.id);
// Verify original team membership is accepted now
const originalMembership = await verifyMembershipExists(
unverifiedUserWithUnacceptedMembership.id,
regularTeam.id
);
expect(originalMembership?.accepted).toBe(true);
});
});
});