Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m11s
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 32s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 31s
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m11s
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 32s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 31s
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)
|
||||
})
|
||||
})
|
||||
73
pitch-deck/app/api/auth/request-link/route.ts
Normal file
73
pitch-deck/app/api/auth/request-link/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { generateToken, getClientIp, logAudit } from '@/lib/auth'
|
||||
import { sendMagicLinkEmail } from '@/lib/email'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
// Generic response returned regardless of whether an investor exists, to
|
||||
// prevent email enumeration. The client always sees the same success message.
|
||||
const GENERIC_RESPONSE = {
|
||||
success: true,
|
||||
message: 'If this email was invited, a fresh access link has been sent.',
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = getClientIp(request) || 'unknown'
|
||||
|
||||
// IP-based rate limit to prevent enumeration / abuse
|
||||
const ipRl = checkRateLimit(`request-link-ip:${ip}`, RATE_LIMITS.authVerify)
|
||||
if (!ipRl.allowed) {
|
||||
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { email } = body
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json({ error: 'Email required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim()
|
||||
|
||||
// Per-email rate limit (silent — same generic response on throttle so callers
|
||||
// can't distinguish a throttled-but-valid email from an unknown one)
|
||||
const emailRl = checkRateLimit(`magic-link:${normalizedEmail}`, RATE_LIMITS.magicLink)
|
||||
if (!emailRl.allowed) {
|
||||
await logAudit(null, 'request_link_throttled', { email: normalizedEmail }, request)
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, status FROM pitch_investors WHERE email = $1`,
|
||||
[normalizedEmail],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
await logAudit(null, 'request_link_unknown_email', { email: normalizedEmail }, request)
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
|
||||
const investor = rows[0]
|
||||
|
||||
if (investor.status === 'revoked') {
|
||||
await logAudit(investor.id, 'request_link_revoked', { email: normalizedEmail }, request)
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
|
||||
const token = generateToken()
|
||||
const ttlHours = parseInt(process.env.MAGIC_LINK_TTL_HOURS || '72')
|
||||
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000)
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO pitch_magic_links (investor_id, token, expires_at) VALUES ($1, $2, $3)`,
|
||||
[investor.id, token, expiresAt],
|
||||
)
|
||||
|
||||
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
|
||||
const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
|
||||
await sendMagicLinkEmail(investor.email, investor.name, magicLinkUrl)
|
||||
|
||||
await logAudit(investor.id, 'request_link_sent', { email: normalizedEmail, expires_at: expiresAt.toISOString() }, request)
|
||||
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
@@ -1,8 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, FormEvent } from 'react'
|
||||
|
||||
type Status = 'idle' | 'submitting' | 'sent' | 'error'
|
||||
|
||||
export default function AuthPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<Status>('idle')
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!email.trim()) return
|
||||
|
||||
setStatus('submitting')
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/request-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
||||
if (res.ok) {
|
||||
setStatus('sent')
|
||||
setMessage(data.message || 'If this email was invited, a fresh access link has been sent.')
|
||||
} else {
|
||||
setStatus('error')
|
||||
setMessage(data.error || 'Something went wrong. Please try again.')
|
||||
}
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setMessage('Network error. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
|
||||
{/* Background gradient */}
|
||||
@@ -35,9 +70,44 @@ export default function AuthPage() {
|
||||
|
||||
<p className="text-white/50 text-sm leading-relaxed mb-6">
|
||||
This interactive pitch deck is available by invitation only.
|
||||
Please check your email for an access link.
|
||||
If you were invited, enter your email below and we'll send you a fresh access link.
|
||||
</p>
|
||||
|
||||
{status === 'sent' ? (
|
||||
<div className="text-left bg-indigo-500/10 border border-indigo-500/20 rounded-lg p-4 mb-5">
|
||||
<p className="text-indigo-200/90 text-sm leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="text-left mb-5">
|
||||
<label htmlFor="email" className="block text-white/60 text-xs mb-2 uppercase tracking-wide">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={status === 'submitting'}
|
||||
placeholder="you@example.com"
|
||||
className="w-full bg-white/[0.04] border border-white/[0.08] rounded-lg px-4 py-3 text-white/90 text-sm placeholder:text-white/20 focus:outline-none focus:border-indigo-400/50 focus:bg-white/[0.06] transition-colors disabled:opacity-50"
|
||||
/>
|
||||
{status === 'error' && message && (
|
||||
<p className="mt-2 text-rose-300/80 text-xs">{message}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'submitting' || !email.trim()}
|
||||
className="w-full mt-4 bg-gradient-to-br from-indigo-500 to-purple-500 hover:from-indigo-400 hover:to-purple-400 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg px-4 py-3 transition-all"
|
||||
>
|
||||
{status === 'submitting' ? 'Sending…' : 'Send access link'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-5">
|
||||
<p className="text-white/30 text-xs">
|
||||
Questions? Contact us at{' '}
|
||||
|
||||
Reference in New Issue
Block a user