Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions src/app/api/auth/signup/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
19 changes: 14 additions & 5 deletions src/app/api/auth/signup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,18 @@ export async function POST(request: NextRequest): Promise<NextResponse> {

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
Expand All @@ -235,13 +244,13 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
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
}
);

Expand Down
Loading