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'
|
'use client'
|
||||||
|
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
import { useState, FormEvent } from 'react'
|
||||||
|
|
||||||
|
type Status = 'idle' | 'submitting' | 'sent' | 'error'
|
||||||
|
|
||||||
export default function AuthPage() {
|
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 (
|
return (
|
||||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
|
<div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
|
||||||
{/* Background gradient */}
|
{/* Background gradient */}
|
||||||
@@ -35,9 +70,44 @@ export default function AuthPage() {
|
|||||||
|
|
||||||
<p className="text-white/50 text-sm leading-relaxed mb-6">
|
<p className="text-white/50 text-sm leading-relaxed mb-6">
|
||||||
This interactive pitch deck is available by invitation only.
|
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>
|
</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">
|
<div className="border-t border-white/[0.06] pt-5">
|
||||||
<p className="text-white/30 text-xs">
|
<p className="text-white/30 text-xs">
|
||||||
Questions? Contact us at{' '}
|
Questions? Contact us at{' '}
|
||||||
|
|||||||
Reference in New Issue
Block a user