diff --git a/src/app/api/auth/signup/route.test.ts b/src/app/api/auth/signup/route.test.ts index a639b159..6b9cf196 100644 --- a/src/app/api/auth/signup/route.test.ts +++ b/src/app/api/auth/signup/route.test.ts @@ -178,6 +178,108 @@ describe('Signup API - POST /api/auth/signup', () => { }); }); + describe('Trial anti-abuse (signup_ip)', () => { + // The DB trigger has already inserted a 3-day trial row by the time signUp + // returns, so the route's upsert must UPDATE that row, not be ignored. + const FIXED_NOW = new Date('2026-03-01T12:00:00.000Z'); + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(FIXED_NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function mockSignUpOk(userId: string) { + mockSignUp.mockResolvedValueOnce({ + data: { + user: { id: userId, email: 'test@example.com', email_confirmed_at: null }, + session: null, + }, + error: null, + }); + } + + function mockIpLookup(rows: Array<{ id: string; user_id: string; status: string; signup_ip: string }>) { + const neq = vi.fn().mockResolvedValue({ data: rows, error: null }); + const eq = vi.fn().mockReturnValue({ neq }); + mockFrom.mockReturnValueOnce({ + select: vi.fn().mockReturnValue({ eq }), + }); + return { eq, neq }; + } + + function mockUpsert() { + const upsert = vi.fn().mockResolvedValueOnce({ error: null }); + mockFrom.mockReturnValueOnce({ upsert }); + return upsert; + } + + function signupRequest(ip: string) { + return new NextRequest('http://localhost/api/auth/signup', { + method: 'POST', + body: JSON.stringify({ email: 'test@example.com', password: 'Password123!' }), + headers: { + 'Content-Type': 'application/json', + 'X-Forwarded-For': `${ip}, 10.0.0.1`, + }, + }); + } + + it('fresh IP: records signup_ip and a 3-day trial, overwriting the trigger row', async () => { + mockSignUpOk('user-fresh'); + const { eq, neq } = mockIpLookup([]); + const upsert = mockUpsert(); + + const { POST } = await import('./route'); + const response = await POST(signupRequest('203.0.113.7')); + expect(response.status).toBe(201); + + // Looked up by the first X-Forwarded-For hop, excluding the new user's own row + expect(eq).toHaveBeenCalledWith('signup_ip', '203.0.113.7'); + expect(neq).toHaveBeenCalledWith('user_id', 'user-fresh'); + + expect(upsert).toHaveBeenCalledTimes(1); + const [payload, options] = upsert.mock.calls[0]; + expect(payload).toEqual({ + user_id: 'user-fresh', + tier: 'trial', + status: 'active', + trial_started_at: '2026-03-01T12:00:00.000Z', + trial_expires_at: '2026-03-04T12:00:00.000Z', + signup_ip: '203.0.113.7', + }); + // Must merge over the trigger's row; ignoreDuplicates would leave signup_ip NULL + expect(options).toEqual({ onConflict: 'user_id', ignoreDuplicates: false }); + }); + + it('repeat IP: records signup_ip and shortens the trial to 1 day', async () => { + mockSignUpOk('user-repeat'); + mockIpLookup([ + { id: 'sub-1', user_id: 'user-earlier', status: 'active', signup_ip: '203.0.113.7' }, + ]); + const upsert = mockUpsert(); + + const { POST } = await import('./route'); + const response = await POST(signupRequest('203.0.113.7')); + expect(response.status).toBe(201); + + expect(upsert).toHaveBeenCalledTimes(1); + const [payload, options] = upsert.mock.calls[0]; + expect(payload).toMatchObject({ + user_id: 'user-repeat', + tier: 'trial', + status: 'active', + trial_started_at: '2026-03-01T12:00:00.000Z', + trial_expires_at: '2026-03-02T12:00:00.000Z', + signup_ip: '203.0.113.7', + }); + expect(options).toEqual({ onConflict: 'user_id', ignoreDuplicates: false }); + }); + }); + describe('Error Handling', () => { it('should return 409 when email already exists', async () => { mockSignUp.mockResolvedValueOnce({ diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 45595dc9..fe41edfd 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -223,9 +223,18 @@ export async function POST(request: NextRequest): Promise { const trialDays = existingCount > 0 ? 1 : 3; - // Create user subscription record (trial tier) - // Use upsert to handle cases where user re-signs up (e.g., unconfirmed email retry) - const trialExpiresAt = new Date(); + // Record the trial this route computed. + // + // The database trigger on_auth_user_created_subscription (create_trial_subscription) + // has ALREADY inserted a plain 3-day trial row for this user by the time signUp + // returns, because it covers OAuth and magic-link signups that never reach this + // route. So the row always exists here, and this upsert must WIN the conflict: + // merge our columns (signup_ip, trial window) over the trigger's row. With + // ignoreDuplicates the row was silently left alone, signup_ip stayed NULL on + // every user and the 1-day repeat-IP trial never applied. Columns not listed + // below (id, created_at, renewal flags, subscription_*) are kept as-is. + const trialStartedAt = new Date(); + const trialExpiresAt = new Date(trialStartedAt); trialExpiresAt.setDate(trialExpiresAt.getDate() + trialDays); const { error: subscriptionError } = await supabase @@ -235,13 +244,13 @@ export async function POST(request: NextRequest): Promise { user_id: data.user.id, tier: 'trial', status: 'active', - trial_started_at: new Date().toISOString(), + trial_started_at: trialStartedAt.toISOString(), trial_expires_at: trialExpiresAt.toISOString(), signup_ip: signupIp, }, { onConflict: 'user_id', - ignoreDuplicates: true, // Don't update if already exists + ignoreDuplicates: false, // UPDATE the trigger's row rather than skipping it } );