-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: move to whitelist database table from growthbook (#864)
* feat: move to whitelist database table from growthbook * fix: use lowercase email in tests * chore: add additional guard clause for email validation * fix: enforce using last part of email address
- Loading branch information
Showing
13 changed files
with
291 additions
and
30 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 19 additions & 0 deletions
19
apps/studio/prisma/migrations/20241107033200_add_whitelist_table/migration.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
-- CreateTable | ||
CREATE TABLE "Whitelist" ( | ||
"id" SERIAL NOT NULL, | ||
"email" TEXT NOT NULL, | ||
"expiry" TIMESTAMP(3), | ||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
|
||
CONSTRAINT "Whitelist_pkey" PRIMARY KEY ("id") | ||
); | ||
|
||
-- CreateIndex | ||
CREATE UNIQUE INDEX "Whitelist_email_key" ON "Whitelist"("email"); | ||
|
||
-- CreateIndex | ||
CREATE INDEX "Whitelist_email_idx" ON "Whitelist"("email"); | ||
|
||
-- AlterTable | ||
CREATE TRIGGER update_timestamp BEFORE UPDATE ON "Whitelist" FOR EACH ROW EXECUTE PROCEDURE moddatetime("updatedAt"); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ import { | |
applySession, | ||
createMockRequest, | ||
} from "tests/integration/helpers/iron-session" | ||
import { setUpWhitelist } from "tests/integration/helpers/seed" | ||
import { describe, expect, it } from "vitest" | ||
|
||
import { env } from "~/env.mjs" | ||
|
@@ -15,16 +16,17 @@ import { getIpFingerprint, LOCALHOST } from "../utils" | |
describe("auth.email", () => { | ||
let caller: Awaited<ReturnType<typeof emailSessionRouter.createCaller>> | ||
let session: ReturnType<typeof applySession> | ||
const TEST_VALID_EMAIL = "[email protected]" | ||
|
||
beforeEach(async () => { | ||
await resetTables("User", "VerificationToken") | ||
await resetTables("User", "VerificationToken", "Whitelist") | ||
await setUpWhitelist({ email: TEST_VALID_EMAIL }) | ||
session = applySession() | ||
const ctx = createMockRequest(session) | ||
caller = emailSessionRouter.createCaller(ctx) | ||
}) | ||
|
||
describe("login", () => { | ||
const TEST_VALID_EMAIL = "[email protected]" | ||
it("should throw if email is not provided", async () => { | ||
// Act | ||
const result = caller.login({ email: "" }) | ||
|
@@ -72,7 +74,6 @@ describe("auth.email", () => { | |
}) | ||
|
||
describe("verifyOtp", () => { | ||
const TEST_VALID_EMAIL = "[email protected]" | ||
const VALID_OTP = "123456" | ||
const VALID_TOKEN_HASH = createTokenHash(VALID_OTP, TEST_VALID_EMAIL) | ||
const INVALID_OTP = "987643" | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
125 changes: 125 additions & 0 deletions
125
apps/studio/src/server/modules/whitelist/__tests__/whitelist.service.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
import { resetTables } from "tests/integration/helpers/db" | ||
import { setUpWhitelist } from "tests/integration/helpers/seed" | ||
|
||
import { isEmailWhitelisted } from "../whitelist.service" | ||
|
||
describe("whitelist.service", () => { | ||
beforeAll(async () => { | ||
const oneYearFromNow = new Date() | ||
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1) | ||
const oneYearAgo = new Date() | ||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) | ||
|
||
await resetTables("Whitelist") | ||
await setUpWhitelist({ email: "[email protected]" }) | ||
await setUpWhitelist({ | ||
email: "[email protected]", | ||
expiry: oneYearFromNow, | ||
}) | ||
await setUpWhitelist({ | ||
email: "[email protected]", | ||
expiry: oneYearAgo, | ||
}) | ||
await setUpWhitelist({ email: ".gov.sg" }) | ||
await setUpWhitelist({ | ||
email: "@vendor.com.sg", | ||
}) | ||
await setUpWhitelist({ | ||
email: "@whitelisted.com.sg", | ||
expiry: oneYearFromNow, | ||
}) | ||
await setUpWhitelist({ email: "@expired.sg", expiry: oneYearAgo }) | ||
await setUpWhitelist({ | ||
email: "[email protected]", | ||
expiry: oneYearAgo, | ||
}) | ||
}) | ||
|
||
it("should show email as whitelisted if the exact email address is whitelisted and expiry is NULL", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
|
||
it("should show email as whitelisted if the exact email address is whitelisted and expiry is in the future", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
|
||
it("should show email as not whitelisted if the exact email address is whitelisted and expiry is in the past", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(false) | ||
}) | ||
|
||
it("should show email as whitelisted if the exact email domain is whitelisted and expiry is NULL", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
|
||
it("should show email as whitelisted if the exact email domain is whitelisted and expiry is in the future", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
|
||
it("should show email as not whitelisted if the exact email domain is whitelisted and expiry is in the past", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(false) | ||
}) | ||
|
||
it("should show email as whitelisted if the suffix of the email domain is whitelisted and expiry is NULL", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
|
||
it("should show email as whitelisted if the exact email address is expired, but the domain's expiry is in the future", async () => { | ||
// Arrange | ||
const email = "[email protected]" | ||
|
||
// Act | ||
const result = await isEmailWhitelisted(email) | ||
|
||
// Assert | ||
expect(result).toBe(true) | ||
}) | ||
}) |
75 changes: 75 additions & 0 deletions
75
apps/studio/src/server/modules/whitelist/whitelist.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import { TRPCError } from "@trpc/server" | ||
|
||
import { isValidEmail } from "~/utils/email" | ||
import { db } from "../database" | ||
|
||
export const isEmailWhitelisted = async (email: string) => { | ||
const lowercaseEmail = email.toLowerCase() | ||
|
||
// Extra guard even if Zod validation has already checked | ||
if (!isValidEmail(lowercaseEmail)) { | ||
throw new TRPCError({ | ||
code: "BAD_REQUEST", | ||
message: "Please sign in with a valid email address.", | ||
}) | ||
} | ||
|
||
// Step 1: Check if the exact email address is whitelisted | ||
const exactMatch = await db | ||
.selectFrom("Whitelist") | ||
.where("email", "=", lowercaseEmail) | ||
.where(({ eb }) => | ||
eb.or([eb("expiry", "is", null), eb("expiry", ">", new Date())]), | ||
) | ||
.select(["id"]) | ||
.executeTakeFirst() | ||
|
||
if (exactMatch) { | ||
return true | ||
} | ||
|
||
// Step 2: Check if the exact email domain is whitelisted | ||
const emailParts = lowercaseEmail.split("@") | ||
if (emailParts.length !== 2) { | ||
throw new TRPCError({ | ||
code: "BAD_REQUEST", | ||
message: "Please sign in with a valid email address.", | ||
}) | ||
} | ||
|
||
const emailDomain = `@${emailParts.pop()}` | ||
const domainMatch = await db | ||
.selectFrom("Whitelist") | ||
.where("email", "=", emailDomain) | ||
.where(({ eb }) => | ||
eb.or([eb("expiry", "is", null), eb("expiry", ">", new Date())]), | ||
) | ||
.select(["id"]) | ||
.executeTakeFirst() | ||
|
||
if (domainMatch) { | ||
return true | ||
} | ||
|
||
// Step 3: Check if the suffix of the email domain is whitelisted | ||
const domainParts = emailDomain.split(".") | ||
for (let i = 1; i < domainParts.length; i++) { | ||
// Suffices should start with a dot (e.g. ".gov.sg") | ||
const suffix = `.${domainParts.slice(i).join(".")}` | ||
|
||
const suffixMatch = await db | ||
.selectFrom("Whitelist") | ||
.where("email", "=", suffix) | ||
.where(({ eb }) => | ||
eb.or([eb("expiry", "is", null), eb("expiry", ">", new Date())]), | ||
) | ||
.select(["id"]) | ||
.executeTakeFirst() | ||
|
||
if (suffixMatch) { | ||
return true | ||
} | ||
} | ||
|
||
return false | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.