Repository navigation
fix(billing): withhold the free allowance from Gmail aliases of an existing account #9418
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
f1a9d8c
fix(billing): withhold the free allowance from Gmail aliases of an ex…
waleedlatif1 aaca509
fix(billing): count only an earlier account as the Gmail inbox holder
waleedlatif1 47e8605
fix(billing): count only a verified earlier account as the Gmail inbo…
waleedlatif1 6c64da6
fix(billing): count any earlier account as the Gmail inbox holder, ve…
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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 hidden or 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 hidden or 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,178 @@ | ||
| /** | ||
| * A dot, `+tag`, or `googlemail.com` variant of an existing Gmail address must not get a second | ||
| * free allowance. Runs against real PostgreSQL through the production signup-stats and | ||
| * fallback-stats paths, and checks the lookup is served by `user_gmail_mailbox_idx`. | ||
| */ | ||
| import { db } from '@sim/db' | ||
| import { gmailMailbox, user, userStats } from '@sim/db/schema' | ||
| import { authMock } from '@sim/testing/mocks/auth.mock' | ||
| import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock' | ||
| import { generateId } from '@sim/utils/id' | ||
| import { eq, inArray, sql } from 'drizzle-orm' | ||
| import { afterAll, describe, expect, it, vi } from 'vitest' | ||
| import { ensureUserStatsExists, handleNewUser } from '@/lib/billing/core/usage' | ||
| import { getFreeTierLimit } from '@/lib/billing/subscriptions/utils' | ||
|
|
||
| vi.mock('@/lib/core/config/env-flags', () => ({ | ||
| ...envFlagsMock, | ||
| isHosted: true, | ||
| isBillingEnabled: true, | ||
| })) | ||
| vi.mock('@/lib/auth', () => authMock) | ||
|
|
||
| const userIds: string[] = [] | ||
|
|
||
| async function insertUser( | ||
| email: string, | ||
| { banned = false, emailVerified = true, createdAt = new Date() } = {} | ||
| ): Promise<string> { | ||
| const id = generateId() | ||
| userIds.push(id) | ||
| await db.insert(user).values({ | ||
| id, | ||
| name: 'Gmail alias fixture', | ||
| email, | ||
| emailVerified, | ||
| banned, | ||
| createdAt, | ||
| updatedAt: createdAt, | ||
| }) | ||
| return id | ||
| } | ||
|
|
||
| async function signUp( | ||
| email: string, | ||
| options?: { banned?: boolean; emailVerified?: boolean } | ||
| ): Promise<string> { | ||
| const id = await insertUser(email, options) | ||
| await handleNewUser(id, {}) | ||
| return id | ||
| } | ||
|
|
||
| async function statsOf(userId: string) { | ||
| const [row] = await db | ||
| .select({ | ||
| freeTierEligible: userStats.freeTierEligible, | ||
| currentUsageLimit: userStats.currentUsageLimit, | ||
| }) | ||
| .from(userStats) | ||
| .where(eq(userStats.userId, userId)) | ||
| return { freeTierEligible: row?.freeTierEligible, limit: Number(row?.currentUsageLimit) } | ||
| } | ||
|
|
||
| /** A Gmail local part no other test run can collide with. */ | ||
| function uniqueLocalPart(): string { | ||
| return `alias${generateId().replaceAll('-', '')}` | ||
| } | ||
|
|
||
| afterAll(async () => { | ||
| if (userIds.length) await db.delete(user).where(inArray(user.id, userIds)) | ||
| }) | ||
|
|
||
| describe('Gmail inboxes and the free allowance', () => { | ||
| it('withholds the allowance from dot, +tag, and googlemail variants of a live account', async () => { | ||
| const local = uniqueLocalPart() | ||
| const original = await signUp(`${local}@gmail.com`) | ||
|
|
||
| const dotted = await signUp(`${local.slice(0, 4)}.${local.slice(4)}@gmail.com`) | ||
| const tagged = await signUp(`${local}+farm@gmail.com`) | ||
| const googlemail = await signUp(`${local.toUpperCase()}@GoogleMail.com`) | ||
|
|
||
| expect(await statsOf(original)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() }) | ||
| for (const variant of [dotted, tagged, googlemail]) { | ||
| expect(await statsOf(variant)).toEqual({ freeTierEligible: false, limit: 0 }) | ||
| } | ||
| }) | ||
|
|
||
| it('withholds it when the inbox belongs to a banned account', async () => { | ||
| const local = uniqueLocalPart() | ||
| await signUp(`${local}@gmail.com`, { banned: true }) | ||
|
|
||
| const returning = await signUp(`${local}+again@gmail.com`) | ||
|
|
||
| expect(await statsOf(returning)).toEqual({ freeTierEligible: false, limit: 0 }) | ||
| }) | ||
|
|
||
| it('withholds it from aliases registered before any of them verified the inbox', async () => { | ||
| const local = uniqueLocalPart() | ||
| const first = await signUp(`${local}@gmail.com`, { emailVerified: false }) | ||
| const second = await signUp(`${local}+two@gmail.com`, { emailVerified: false }) | ||
| await db | ||
| .update(user) | ||
| .set({ emailVerified: true }) | ||
| .where(inArray(user.id, [first, second])) | ||
|
|
||
| const third = await signUp(`${local.slice(0, 5)}.${local.slice(5)}@gmail.com`) | ||
|
|
||
| expect(await statsOf(first)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() }) | ||
| expect(await statsOf(second)).toEqual({ freeTierEligible: false, limit: 0 }) | ||
| expect(await statsOf(third)).toEqual({ freeTierEligible: false, limit: 0 }) | ||
| }) | ||
|
|
||
| it('grants it to a different Gmail inbox and never folds addresses outside Gmail', async () => { | ||
| const local = uniqueLocalPart() | ||
| await signUp(`${local}@gmail.com`) | ||
| await signUp(`${local}+one@gmail-alias.test`) | ||
|
|
||
| const otherInbox = await signUp(`${local}x@gmail.com`) | ||
| const otherDomain = await signUp(`${local}@gmail-alias.test`) | ||
|
|
||
| expect(await statsOf(otherInbox)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() }) | ||
| expect(await statsOf(otherDomain)).toEqual({ | ||
| freeTierEligible: true, | ||
| limit: getFreeTierLimit(), | ||
| }) | ||
| }) | ||
|
|
||
| it('withholds it from a stats row the fallback creates after signup failed to', async () => { | ||
| const local = uniqueLocalPart() | ||
| await signUp(`${local}@gmail.com`) | ||
| const alias = await insertUser(`${local.slice(0, 3)}.${local.slice(3)}@gmail.com`) | ||
|
|
||
| await ensureUserStatsExists(alias) | ||
|
|
||
| expect(await statsOf(alias)).toEqual({ freeTierEligible: false, limit: 0 }) | ||
| }) | ||
|
|
||
| it('keeps the allowance with the inbox’s first account when its stats row comes after an alias’s', async () => { | ||
| const local = uniqueLocalPart() | ||
| const original = await insertUser(`${local}@gmail.com`, { | ||
| createdAt: new Date(Date.now() - 60_000), | ||
| }) | ||
| const alias = await signUp(`${local}+later@gmail.com`) | ||
|
|
||
| await ensureUserStatsExists(original) | ||
|
|
||
| expect(await statsOf(original)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() }) | ||
| expect(await statsOf(alias)).toEqual({ freeTierEligible: false, limit: 0 }) | ||
| }) | ||
|
|
||
| it('withholds from exactly one of two accounts created at the same instant', async () => { | ||
| const local = uniqueLocalPart() | ||
| const createdAt = new Date() | ||
| const first = await insertUser(`${local}@gmail.com`, { createdAt }) | ||
| const second = await insertUser(`${local.slice(0, 2)}.${local.slice(2)}@gmail.com`, { | ||
| createdAt, | ||
| }) | ||
|
|
||
| await ensureUserStatsExists(first) | ||
| await ensureUserStatsExists(second) | ||
|
|
||
| const eligible = [await statsOf(first), await statsOf(second)].map( | ||
| (stats) => stats.freeTierEligible | ||
| ) | ||
| expect(eligible.sort()).toEqual([false, true]) | ||
| }) | ||
|
|
||
| it('finds the inbox through user_gmail_mailbox_idx rather than a scan of user', async () => { | ||
| const plan = await db.transaction(async (tx) => { | ||
| await tx.execute(sql`SET LOCAL enable_seqscan = off`) | ||
| const rows = await tx.execute<{ 'QUERY PLAN': string }>( | ||
| sql`EXPLAIN SELECT ${user.id} FROM ${user} WHERE ${gmailMailbox(user.email)} = ${`${uniqueLocalPart()}@gmail.com`} LIMIT 1` | ||
| ) | ||
| return rows.map((row) => row['QUERY PLAN']).join('\n') | ||
| }) | ||
|
|
||
| expect(plan).toContain('user_gmail_mailbox_idx') | ||
| }) | ||
| }) |
This file contains hidden or 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,67 @@ | ||
| import { db } from '@sim/db' | ||
| import { gmailMailbox, user } from '@sim/db/schema' | ||
| import { createLogger } from '@sim/logger' | ||
| import { and, eq, lt, or, sql } from 'drizzle-orm' | ||
| import { isBillingEnabled } from '@/lib/core/config/env-flags' | ||
| import type { DbOrTx } from '@/lib/db/types' | ||
|
|
||
| /** | ||
| * One free allowance per Gmail inbox. Gmail delivers every dot and `+tag` | ||
| * variant of an address, and its `googlemail.com` twin, to the same inbox, so | ||
| * without this one person could register `a.b@gmail.com`, `ab+1@gmail.com`, … | ||
| * for a fresh allowance each time. The later account is created normally but | ||
| * without the free allowance, like a returning deleted-account identity. | ||
| * | ||
| * Only meaningful where billing is enabled. | ||
| */ | ||
|
|
||
| const logger = createLogger('GmailAlias') | ||
|
|
||
| /** | ||
| * Whether an earlier account, banned or not, already holds the Gmail inbox this | ||
| * user's address delivers to. Only an account created before this one counts | ||
| * (the id breaks a creation-time tie), so the inbox's first account keeps the | ||
| * allowance however late its own stats row is created. The earlier account | ||
| * counts whether or not it verified the address: the allowance is granted at | ||
| * signup, before verification, and where verification is off a password | ||
| * account never verifies, so a verified-only rule would let every alias | ||
| * registered unverified keep its allowance. Pass the caller's transaction as | ||
| * `executor` to read on it; the read then runs in a savepoint, so a failed | ||
| * lookup never aborts that transaction. Fails open: a lookup error returns | ||
| * `false` and is logged, because refusing a legitimate person's allowance is | ||
| * worse than one missed match. | ||
| */ | ||
| export async function userHasGmailAliasAccount( | ||
| userId: string, | ||
| executor: DbOrTx = db | ||
| ): Promise<boolean> { | ||
| if (!isBillingEnabled) return false | ||
| const read = async (reader: DbOrTx) => { | ||
| const [self] = await reader | ||
| .select({ mailbox: gmailMailbox(user.email), createdAt: user.createdAt }) | ||
| .from(user) | ||
| .where(eq(user.id, userId)) | ||
| .limit(1) | ||
| if (!self?.mailbox) return false | ||
| const [other] = await reader | ||
| .select({ id: user.id }) | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| .from(user) | ||
| .where( | ||
| and( | ||
| sql`${gmailMailbox(user.email)} = ${self.mailbox}`, | ||
| or( | ||
| lt(user.createdAt, self.createdAt), | ||
| and(eq(user.createdAt, self.createdAt), lt(user.id, userId)) | ||
| ) | ||
| ) | ||
| ) | ||
| .limit(1) | ||
| return Boolean(other) | ||
| } | ||
| try { | ||
| return await (executor === db ? read(db) : executor.transaction(read)) | ||
| } catch (error) { | ||
| logger.error('Gmail alias lookup failed; granting the free allowance', { userId, error }) | ||
| return false | ||
| } | ||
| } | ||
This file contains hidden or 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,6 @@ | ||
| COMMIT;--> statement-breakpoint | ||
| SET lock_timeout = 0;--> statement-breakpoint | ||
| -- migration-safe: this new additive index has no deployed readers; remove any unjournaled prior build before rebuilding concurrently on replay. | ||
| DROP INDEX CONCURRENTLY IF EXISTS "user_gmail_mailbox_idx";--> statement-breakpoint | ||
| CREATE INDEX CONCURRENTLY IF NOT EXISTS "user_gmail_mailbox_idx" ON "user" USING btree ((CASE WHEN split_part(lower(btrim("email")), '@', 2) IN ('gmail.com', 'googlemail.com') THEN replace(split_part(split_part(lower(btrim("email")), '@', 1), '+', 1), '.', '') || '@gmail.com' END)) WHERE (CASE WHEN split_part(lower(btrim("user"."email")), '@', 2) IN ('gmail.com', 'googlemail.com') THEN replace(split_part(split_part(lower(btrim("user"."email")), '@', 1), '+', 1), '.', '') || '@gmail.com' END) IS NOT NULL;--> statement-breakpoint | ||
| SET lock_timeout = '5s'; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.