feat(pitch-deck): self-service magic-link reissue on /auth
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 31s
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 31s
Investors who lost their session or whose invite token was already used can now enter their email on /auth to receive a fresh access link, without needing a manual re-invite from an admin. - New /api/auth/request-link endpoint looks up the investor by email, issues a new pitch_magic_links row, and emails the link via the existing sendMagicLinkEmail path. Response is generic regardless of whether the email exists (enumeration resistance) and silently no-ops for revoked investors. - Rate-limited both per-IP (authVerify preset) and per-email (magicLink preset, 3/hour — same ceiling as admin-invite/resend). - /auth page now renders an email form; submits to the new endpoint and shows a generic "if invited, link sent" confirmation. - Route-level tests cover validation, normalization, unknown email, revoked investor, and both rate-limit paths. - End-to-end regression test wires request-link + verify against an in-memory fake DB and asserts the full flow: original invite used → replay rejected → email submission → fresh link → verify succeeds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
294
pitch-deck/__tests__/api/reissue-regression.test.ts
Normal file
294
pitch-deck/__tests__/api/reissue-regression.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Regression test for the "lost access" scenario:
|
||||
*
|
||||
* 1. Admin invites investor A → token T1 is created and emailed.
|
||||
* 2. Investor A opens the link successfully → T1 is marked used_at.
|
||||
* 3. Investor A clears their session (or a redeploy drops cookies).
|
||||
* 4. Investor A returns to / — redirected to /auth.
|
||||
* 5. Without this feature, A is stuck: T1 is already used, expired, or the
|
||||
* session is gone, and there is no self-service way to get back in.
|
||||
* 6. With this feature, A enters their email on /auth and the endpoint
|
||||
* issues a brand new, unused magic link T2 for the same investor row.
|
||||
*
|
||||
* This test wires together the request-link handler with the real verify
|
||||
* handler against an in-memory fake of the two tables the flow touches
|
||||
* (pitch_investors, pitch_magic_links) so we can assert end-to-end that a
|
||||
* second link works after the first one was used.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
// ---- In-memory fake of the two tables touched by this flow ----
|
||||
|
||||
interface InvestorRow {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
status: 'invited' | 'active' | 'revoked'
|
||||
last_login_at: Date | null
|
||||
login_count: number
|
||||
}
|
||||
interface MagicLinkRow {
|
||||
id: string
|
||||
investor_id: string
|
||||
token: string
|
||||
expires_at: Date
|
||||
used_at: Date | null
|
||||
ip_address: string | null
|
||||
user_agent: string | null
|
||||
}
|
||||
|
||||
const db = {
|
||||
investors: [] as InvestorRow[],
|
||||
magicLinks: [] as MagicLinkRow[],
|
||||
sessions: [] as { id: string; investor_id: string; ip_address: string | null }[],
|
||||
}
|
||||
|
||||
let idCounter = 0
|
||||
const nextId = () => `row-${++idCounter}`
|
||||
|
||||
// A tiny query router: match the SQL fragment we care about, ignore the rest.
|
||||
const queryMock = vi.fn(async (sql: string, params: unknown[] = []) => {
|
||||
const s = sql.replace(/\s+/g, ' ').trim()
|
||||
|
||||
// Investor lookup by email (used by request-link)
|
||||
if (/SELECT id, email, name, status FROM pitch_investors WHERE email = \$1/i.test(s)) {
|
||||
const row = db.investors.find(i => i.email === params[0])
|
||||
return { rows: row ? [row] : [] }
|
||||
}
|
||||
|
||||
// Insert magic link
|
||||
if (/INSERT INTO pitch_magic_links \(investor_id, token, expires_at\)/i.test(s)) {
|
||||
db.magicLinks.push({
|
||||
id: nextId(),
|
||||
investor_id: params[0] as string,
|
||||
token: params[1] as string,
|
||||
expires_at: params[2] as Date,
|
||||
used_at: null,
|
||||
ip_address: null,
|
||||
user_agent: null,
|
||||
})
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
// Verify: magic link + investor JOIN lookup
|
||||
if (/FROM pitch_magic_links ml JOIN pitch_investors i/i.test(s)) {
|
||||
const link = db.magicLinks.find(ml => ml.token === params[0])
|
||||
if (!link) return { rows: [] }
|
||||
const inv = db.investors.find(i => i.id === link.investor_id)!
|
||||
return {
|
||||
rows: [{
|
||||
id: link.id,
|
||||
investor_id: link.investor_id,
|
||||
expires_at: link.expires_at,
|
||||
used_at: link.used_at,
|
||||
email: inv.email,
|
||||
investor_status: inv.status,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
// Mark magic link used
|
||||
if (/UPDATE pitch_magic_links SET used_at = NOW/i.test(s)) {
|
||||
const link = db.magicLinks.find(ml => ml.id === params[2])
|
||||
if (link) {
|
||||
link.used_at = new Date()
|
||||
link.ip_address = params[0] as string | null
|
||||
link.user_agent = params[1] as string | null
|
||||
}
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
// Activate investor
|
||||
if (/UPDATE pitch_investors SET status = 'active'/i.test(s)) {
|
||||
const inv = db.investors.find(i => i.id === params[0])
|
||||
if (inv) {
|
||||
inv.status = 'active'
|
||||
inv.last_login_at = new Date()
|
||||
inv.login_count += 1
|
||||
}
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
// createSession: revoke prior sessions (no-op in fake)
|
||||
if (/UPDATE pitch_sessions SET revoked = true WHERE investor_id/i.test(s)) {
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
// createSession: insert
|
||||
if (/INSERT INTO pitch_sessions/i.test(s)) {
|
||||
const id = nextId()
|
||||
db.sessions.push({ id, investor_id: params[0] as string, ip_address: params[2] as string | null })
|
||||
return { rows: [{ id }] }
|
||||
}
|
||||
|
||||
// createSession: fetch investor email for JWT
|
||||
if (/SELECT email FROM pitch_investors WHERE id = \$1/i.test(s)) {
|
||||
const inv = db.investors.find(i => i.id === params[0])
|
||||
return { rows: inv ? [{ email: inv.email }] : [] }
|
||||
}
|
||||
|
||||
// new-ip detection query (verify route)
|
||||
if (/SELECT DISTINCT ip_address FROM pitch_sessions/i.test(s)) {
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
// Audit log insert — accept everything
|
||||
if (/INSERT INTO pitch_audit_logs/i.test(s)) {
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
throw new Error(`Unmocked query: ${s.slice(0, 120)}…`)
|
||||
})
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
default: { query: (...args: unknown[]) => queryMock(args[0] as string, args[1] as unknown[]) },
|
||||
}))
|
||||
|
||||
// Capture emails instead of sending them
|
||||
const sentEmails: Array<{ to: string; url: string }> = []
|
||||
vi.mock('@/lib/email', () => ({
|
||||
sendMagicLinkEmail: vi.fn(async (to: string, _name: string | null, url: string) => {
|
||||
sentEmails.push({ to, url })
|
||||
}),
|
||||
}))
|
||||
|
||||
// next/headers cookies() needs to be stubbed — setSessionCookie calls it.
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Import the handlers AFTER mocks are set up
|
||||
import { POST as requestLink } from '@/app/api/auth/request-link/route'
|
||||
import { POST as verifyLink } from '@/app/api/auth/verify/route'
|
||||
|
||||
function makeJsonRequest(url: string, body: unknown, ip = '203.0.113.1'): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function extractToken(url: string): string {
|
||||
const m = url.match(/token=([0-9a-f]+)/)
|
||||
if (!m) throw new Error(`No token in url: ${url}`)
|
||||
return m[1]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.investors = []
|
||||
db.magicLinks = []
|
||||
db.sessions = []
|
||||
sentEmails.length = 0
|
||||
idCounter = 0
|
||||
queryMock.mockClear()
|
||||
})
|
||||
|
||||
describe('Regression: investor can re-request a working magic link after the first is consumed', () => {
|
||||
it('full flow — invite → use → request-link → new link works', async () => {
|
||||
// --- Setup: admin has already invited the investor (simulate the outcome) ---
|
||||
const investorId = 'investor-42'
|
||||
db.investors.push({
|
||||
id: investorId,
|
||||
email: 'vc@example.com',
|
||||
name: 'VC Partner',
|
||||
company: 'Acme Capital',
|
||||
status: 'invited',
|
||||
last_login_at: null,
|
||||
login_count: 0,
|
||||
})
|
||||
db.magicLinks.push({
|
||||
id: 'ml-original',
|
||||
investor_id: investorId,
|
||||
token: 'a'.repeat(96), // original invite token
|
||||
expires_at: new Date(Date.now() + 72 * 60 * 60 * 1000),
|
||||
used_at: null,
|
||||
ip_address: null,
|
||||
user_agent: null,
|
||||
})
|
||||
|
||||
// --- Step 1: investor uses the original invite link ---
|
||||
const firstVerify = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: 'a'.repeat(96) }))
|
||||
expect(firstVerify.status).toBe(200)
|
||||
const first = db.magicLinks.find(ml => ml.id === 'ml-original')!
|
||||
expect(first.used_at).not.toBeNull()
|
||||
|
||||
// --- Step 2: investor comes back later; clicks the same link → rejected ---
|
||||
const replay = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: 'a'.repeat(96) }))
|
||||
expect(replay.status).toBe(401)
|
||||
const replayBody = await replay.json()
|
||||
expect(replayBody.error).toMatch(/already been used/i)
|
||||
|
||||
// --- Step 3: investor visits /auth and submits their email ---
|
||||
const reissue = await requestLink(
|
||||
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'vc@example.com' }, '203.0.113.99'),
|
||||
)
|
||||
expect(reissue.status).toBe(200)
|
||||
const reissueBody = await reissue.json()
|
||||
expect(reissueBody.success).toBe(true)
|
||||
|
||||
// --- Step 4: a fresh email was dispatched to the investor ---
|
||||
expect(sentEmails).toHaveLength(1)
|
||||
expect(sentEmails[0].to).toBe('vc@example.com')
|
||||
const newToken = extractToken(sentEmails[0].url)
|
||||
expect(newToken).not.toBe('a'.repeat(96))
|
||||
expect(newToken).toMatch(/^[0-9a-f]{96}$/)
|
||||
|
||||
// A second unused magic link row exists for the same investor
|
||||
const links = db.magicLinks.filter(ml => ml.investor_id === investorId)
|
||||
expect(links).toHaveLength(2)
|
||||
const newLink = links.find(ml => ml.token === newToken)!
|
||||
expect(newLink.used_at).toBeNull()
|
||||
|
||||
// --- Step 5: the new token validates successfully ---
|
||||
const secondVerify = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: newToken }))
|
||||
expect(secondVerify.status).toBe(200)
|
||||
const secondBody = await secondVerify.json()
|
||||
expect(secondBody.success).toBe(true)
|
||||
expect(secondBody.redirect).toBe('/')
|
||||
|
||||
// And the new link is now used, mirroring the one-time-use contract
|
||||
expect(newLink.used_at).not.toBeNull()
|
||||
})
|
||||
|
||||
it('unknown emails do not create magic links or send email (prevents enumeration & abuse)', async () => {
|
||||
// No investors in the DB
|
||||
const res = await requestLink(
|
||||
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'stranger@example.com' }),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
// Same generic message as the happy path
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.message).toMatch(/if this email was invited/i)
|
||||
|
||||
expect(sentEmails).toHaveLength(0)
|
||||
expect(db.magicLinks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('revoked investors cannot self-serve a new link', async () => {
|
||||
db.investors.push({
|
||||
id: 'revoked-1',
|
||||
email: 'gone@example.com',
|
||||
name: null,
|
||||
company: null,
|
||||
status: 'revoked',
|
||||
last_login_at: null,
|
||||
login_count: 0,
|
||||
})
|
||||
|
||||
const res = await requestLink(
|
||||
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'gone@example.com' }),
|
||||
)
|
||||
expect(res.status).toBe(200) // generic success (no info leak)
|
||||
expect(sentEmails).toHaveLength(0)
|
||||
expect(db.magicLinks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
213
pitch-deck/__tests__/api/request-link.test.ts
Normal file
213
pitch-deck/__tests__/api/request-link.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
// Mock the DB pool before the route is imported
|
||||
const queryMock = vi.fn()
|
||||
vi.mock('@/lib/db', () => ({
|
||||
default: { query: (...args: unknown[]) => queryMock(...args) },
|
||||
}))
|
||||
|
||||
// Mock the email sender so no SMTP is attempted
|
||||
const sendMagicLinkEmailMock = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mock('@/lib/email', () => ({
|
||||
sendMagicLinkEmail: (...args: unknown[]) => sendMagicLinkEmailMock(...args),
|
||||
}))
|
||||
|
||||
// Import after mocks are registered
|
||||
import { POST } from '@/app/api/auth/request-link/route'
|
||||
|
||||
// Unique suffix per test so the rate-limit store (keyed by IP / email) doesn't
|
||||
// bleed across cases — the rate-limiter holds state at module scope.
|
||||
let testId = 0
|
||||
function uniqueIp() {
|
||||
testId++
|
||||
return `10.0.${Math.floor(testId / 250)}.${testId % 250}`
|
||||
}
|
||||
|
||||
function makeRequest(body: unknown, ip = uniqueIp()): NextRequest {
|
||||
return new NextRequest('http://localhost/api/auth/request-link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-forwarded-for': ip,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function investorRow(overrides: Partial<{ id: string; email: string; name: string | null; status: string }> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? 'investor-1',
|
||||
email: overrides.email ?? 'invited@example.com',
|
||||
name: overrides.name ?? 'Alice',
|
||||
status: overrides.status ?? 'invited',
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
queryMock.mockReset()
|
||||
sendMagicLinkEmailMock.mockReset()
|
||||
sendMagicLinkEmailMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('POST /api/auth/request-link — input validation', () => {
|
||||
it('returns 400 when email is missing', async () => {
|
||||
const res = await POST(makeRequest({}))
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toBe('Email required')
|
||||
expect(queryMock).not.toHaveBeenCalled()
|
||||
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when email is not a string', async () => {
|
||||
const res = await POST(makeRequest({ email: 12345 }))
|
||||
expect(res.status).toBe(400)
|
||||
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles malformed JSON body as missing email (400)', async () => {
|
||||
const req = new NextRequest('http://localhost/api/auth/request-link', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': uniqueIp() },
|
||||
body: 'not-json',
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/request-link — unknown email (enumeration resistance)', () => {
|
||||
it('returns the generic success response without sending email', async () => {
|
||||
// First query: investor lookup → empty rows
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
// Second query: the audit log insert
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
|
||||
const res = await POST(makeRequest({ email: 'unknown@example.com' }))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.message).toMatch(/if this email was invited/i)
|
||||
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
|
||||
|
||||
// Verify the investor-lookup SQL was issued with the normalized email
|
||||
const [sql, params] = queryMock.mock.calls[0]
|
||||
expect(sql).toMatch(/FROM pitch_investors WHERE email/i)
|
||||
expect(params).toEqual(['unknown@example.com'])
|
||||
})
|
||||
|
||||
it('normalizes email (trim + lowercase) before lookup', async () => {
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
|
||||
await POST(makeRequest({ email: ' Mixed@Example.COM ' }))
|
||||
|
||||
const [, params] = queryMock.mock.calls[0]
|
||||
expect(params).toEqual(['mixed@example.com'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/request-link — known investor', () => {
|
||||
it('creates a new magic link and sends the email with generic response', async () => {
|
||||
// 1st: investor lookup → found
|
||||
queryMock.mockResolvedValueOnce({ rows: [investorRow()] })
|
||||
// 2nd: magic link insert
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
// 3rd: audit log insert
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
|
||||
const res = await POST(makeRequest({ email: 'invited@example.com' }))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.success).toBe(true)
|
||||
// Response is identical to the unknown-email case (no information leak)
|
||||
expect(body.message).toMatch(/if this email was invited/i)
|
||||
|
||||
// Verify magic link insert
|
||||
const [insertSql, insertParams] = queryMock.mock.calls[1]
|
||||
expect(insertSql).toMatch(/INSERT INTO pitch_magic_links/i)
|
||||
expect(insertParams[0]).toBe('investor-1')
|
||||
expect(insertParams[1]).toMatch(/^[0-9a-f]{96}$/) // 96-char hex token
|
||||
expect(insertParams[2]).toBeInstanceOf(Date)
|
||||
|
||||
// Verify email was sent with the fresh token URL
|
||||
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(1)
|
||||
const [emailTo, emailName, magicLinkUrl] = sendMagicLinkEmailMock.mock.calls[0]
|
||||
expect(emailTo).toBe('invited@example.com')
|
||||
expect(emailName).toBe('Alice')
|
||||
expect(magicLinkUrl).toMatch(/\/auth\/verify\?token=[0-9a-f]{96}$/)
|
||||
})
|
||||
|
||||
it('generates a different token on each call (re-invite is always fresh)', async () => {
|
||||
// Call 1
|
||||
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email: 'a@x.com' })] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
await POST(makeRequest({ email: 'a@x.com' }))
|
||||
|
||||
// Call 2 — different email to avoid the per-email rate limit
|
||||
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email: 'b@x.com' })] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] })
|
||||
await POST(makeRequest({ email: 'b@x.com' }))
|
||||
|
||||
const token1 = queryMock.mock.calls[1][1][1]
|
||||
const token2 = queryMock.mock.calls[4][1][1]
|
||||
expect(token1).not.toBe(token2)
|
||||
})
|
||||
|
||||
it('skips email send for a revoked investor (returns generic response)', async () => {
|
||||
queryMock.mockResolvedValueOnce({ rows: [investorRow({ status: 'revoked' })] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log
|
||||
|
||||
const res = await POST(makeRequest({ email: 'invited@example.com' }))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
|
||||
|
||||
// Ensure no magic link was inserted
|
||||
const inserts = queryMock.mock.calls.filter(c => /INSERT INTO pitch_magic_links/i.test(c[0]))
|
||||
expect(inserts.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/request-link — rate limiting', () => {
|
||||
it('throttles after N requests per email and returns generic success (silent throttle)', async () => {
|
||||
const email = `throttle-${Date.now()}@example.com`
|
||||
|
||||
// First 3 requests succeed (RATE_LIMITS.magicLink.limit = 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email })] })
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // magic link insert
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log
|
||||
const res = await POST(makeRequest({ email }))
|
||||
expect(res.status).toBe(200)
|
||||
}
|
||||
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
// 4th request is silently throttled — same generic response, no email sent
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log only
|
||||
const res4 = await POST(makeRequest({ email }))
|
||||
expect(res4.status).toBe(200)
|
||||
const body4 = await res4.json()
|
||||
expect(body4.success).toBe(true)
|
||||
// Still exactly 3 emails sent — nothing new
|
||||
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('throttles with 429 after too many attempts from the same IP', async () => {
|
||||
const ip = '172.31.99.99'
|
||||
// RATE_LIMITS.authVerify.limit = 10 for IP-scoped checks
|
||||
for (let i = 0; i < 10; i++) {
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // investor lookup returns empty
|
||||
queryMock.mockResolvedValueOnce({ rows: [] }) // audit
|
||||
const res = await POST(makeRequest({ email: `ip-test-${i}@example.com` }, ip))
|
||||
expect(res.status).toBe(200)
|
||||
}
|
||||
|
||||
const res = await POST(makeRequest({ email: 'final@example.com' }, ip))
|
||||
expect(res.status).toBe(429)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user