Skip to content

Commit 34f1f71

Browse files
committed
fix(webapp): store invite addresses folded
The pending-invite check is a read followed by a write, and the @@unique([organizationId, email]) constraint behind it is case-sensitive, so it could not catch a concurrent insert of a differently cased variant. Writing the folded address makes every row for an address identical, which lets that constraint act as the backstop it was meant to be, and stops inviteMembers depending on its callers to normalise first.
1 parent a794057 commit 34f1f71

2 files changed

Lines changed: 49 additions & 1 deletion

File tree

‎apps/webapp/app/models/member.server.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,11 @@ export async function inviteMembers({
182182
try {
183183
const invite = await prisma.orgMemberInvite.create({
184184
data: {
185-
email,
185+
// Store folded. The @@unique([organizationId, email]) backstop is
186+
// case-sensitive, so it only catches a concurrent duplicate if every
187+
// row for an address is written the same way — this keeps that true
188+
// regardless of what a caller passes in.
189+
email: folded,
186190
token: tokenGenerator(),
187191
organizationId: org.id,
188192
inviterId: userId,

‎apps/webapp/test/inviteEmailCase.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,48 @@ describe("invite email casing", () => {
237237
expect(after).toBe(before);
238238
}
239239
);
240+
postgresTest(
241+
"inviteMembers stores addresses folded so the unique constraint can backstop",
242+
{ timeout: 60_000 },
243+
async ({ prisma }) => {
244+
prismaHolder.client = prisma;
245+
const { inviteMembers } = await import("../app/models/member.server");
246+
247+
const suffix = randomHex(8);
248+
const owner = await prisma.user.create({
249+
data: { email: `owner-${suffix}@example.test`, authenticationMethod: "MAGIC_LINK" },
250+
});
251+
const organization = await prisma.organization.create({
252+
data: {
253+
title: `invite-fold-org-${suffix}`,
254+
slug: `invite-fold-org-${suffix}`,
255+
isActivated: true,
256+
members: { create: { userId: owner.id, role: "ADMIN" } },
257+
},
258+
});
259+
260+
// Called directly with mixed case, i.e. not relying on a route to
261+
// normalise first, since the constraint is the last line of defence.
262+
const first = await inviteMembers({
263+
slug: organization.slug,
264+
emails: [`Fresh-${suffix}@Example.Test`],
265+
userId: owner.id,
266+
});
267+
expect(first.created).toHaveLength(1);
268+
expect(first.created[0].email).toBe(`fresh-${suffix}@example.test`);
269+
270+
// A differently cased variant must not become a second row.
271+
const second = await inviteMembers({
272+
slug: organization.slug,
273+
emails: [`FRESH-${suffix}@EXAMPLE.TEST`],
274+
userId: owner.id,
275+
});
276+
expect(second.created).toHaveLength(0);
277+
278+
const rows = await prisma.orgMemberInvite.count({
279+
where: { organizationId: organization.id },
280+
});
281+
expect(rows).toBe(1);
282+
}
283+
);
240284
});

0 commit comments

Comments
 (0)