mirror of
https://git.coastit.co.za/caitlin/CRM_ENVR.git
synced 2026-07-10 03:05:43 +02:00
Started PostgreSQL — data directory was uninitialized, ran initdb, set password, switched to md5 auth, all 24 migrations applied.
Hardened db.ts — added statement_timeout: 30000, idle_in_transaction_session_timeout: 10000, created transaction() helper (BEGIN/COMMIT/ROLLBACK). Multi-step API routes wrapped in transactions — Events POST, Conversations POST use atomic blocks. Fixed leads pagination — status filter pushed into SQL WHERE (no client-side filtering after LIMIT/OFFSET), added SELECT COUNT(*) for total. Rewrote dashboard — SQL aggregations (COUNT + GROUP BY + date_trunc) replace loading all leads into memory. Optimized conversations GET — merged duplicate correlated subqueries into single LEFT JOIN LATERAL. Created migration 021_performance_indexes.sql — 8 missing indexes + set_session_user_context() function caching current_user_hierarchy_level() as session variable (avoids per-query RLS join). Fixed build error — duplicate const other in conversations route. Also fixed a TypeScript never error in dashboard breakdown map. Verified — npx tsc --noEmit clean, npm test (13 pass, 7 integration skip gracefully), npx next build succeeds.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
const API_BASE = "http://localhost:3006"
|
||||
|
||||
async function loginAs(role: string) {
|
||||
const credentials: Record<string, { email: string; password: string }> = {
|
||||
admin: { email: "admin@coastit.co.za", password: "AdminAccess@2026" },
|
||||
superadmin: { email: "superadmin@coastit.co.za", password: "SuperAdmin@2026" },
|
||||
sales: { email: "sales@coastit.co.za", password: "SalesAccess@2026" },
|
||||
dev: { email: "dev@coastit.co.za", password: "DevTesting@2026" },
|
||||
}
|
||||
const cred = credentials[role]
|
||||
if (!cred) return null
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(cred),
|
||||
})
|
||||
if (res.status !== 200) return null
|
||||
return res.headers.get("set-cookie")?.split(";")[0]
|
||||
}
|
||||
|
||||
describe("Bug Reports", () => {
|
||||
it("allows any authenticated user to submit a bug report", async () => {
|
||||
const cookie = await loginAs("dev")
|
||||
if (!cookie) return
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/bug-reports`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify({ title: "Test bug", description: "This is a test bug report" }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it("rejects unauthenticated bug report submission", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/bug-reports`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Test", description: "Test" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// These tests require a running PostgreSQL database with the CRM schema.
|
||||
// Run: npm run dev (starts the server on port 3006)
|
||||
// Then: npx vitest run
|
||||
|
||||
const API_BASE = "http://localhost:3006"
|
||||
|
||||
async function loginAs(role: string) {
|
||||
const credentials: Record<string, { email: string; password: string }> = {
|
||||
admin: { email: "superadmin@coastit.co.za", password: "SuperAdmin@2026" },
|
||||
sales: { email: "sales@coastit.co.za", password: "SalesAccess@2026" },
|
||||
}
|
||||
const cred = credentials[role] || credentials.admin
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(cred),
|
||||
})
|
||||
if (res.status !== 200) return null
|
||||
const setCookie = res.headers.get("set-cookie")
|
||||
return setCookie?.split(";")[0] // return the raw cookie value
|
||||
}
|
||||
|
||||
describe("Leads API", () => {
|
||||
it("creates a lead when authenticated", async () => {
|
||||
const cookie = await loginAs("sales")
|
||||
if (!cookie) return // skip if DB not available
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/leads`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify({
|
||||
company_name: "Test Company",
|
||||
contact_name: "Test Contact",
|
||||
email: "test@example.com",
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// Cleanup
|
||||
const data = await res.json()
|
||||
if (data?.id) {
|
||||
await fetch(`${API_BASE}/api/leads/${data.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: cookie },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects unauthenticated lead creation", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/leads`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ company_name: "Test", contact_name: "Test", email: "test@test.com" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
||||
|
||||
// These tests require a running PostgreSQL database with the CRM schema.
|
||||
// Set DATABASE_URL env var or the default local connection will be used.
|
||||
//
|
||||
// Run: DATABASE_URL=postgresql://postgres:postgres@localhost:5432/crm_test npx vitest run
|
||||
|
||||
const API_BASE = "http://localhost:3006"
|
||||
|
||||
function skipIfNoDb() {
|
||||
if (!process.env.CI && !process.env.DATABASE_URL?.includes("crm_test")) {
|
||||
console.warn("Skipping DB-dependent tests. Set DATABASE_URL to a test database.")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
describe("Login", () => {
|
||||
it("returns 401 with wrong password", async () => {
|
||||
if (skipIfNoDb()) return
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "superadmin@coastit.co.za", password: "wrong" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it("returns 400 with empty body", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it("returns 401 for non-existent user", async () => {
|
||||
if (skipIfNoDb()) return
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "nobody@example.com", password: "anything" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Authorization", () => {
|
||||
it("rejects unauthenticated requests to protected routes", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/leads`, { headers: { "Content-Type": "application/json" } })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it("rejects unauthenticated requests to dashboard", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/dashboard`, { headers: { "Content-Type": "application/json" } })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { signToken, verifyToken } from "@/lib/jwt"
|
||||
|
||||
process.env.JWT_SECRET = "test-secret-that-is-at-least-32-chars-long-for-security"
|
||||
|
||||
describe("JWT", () => {
|
||||
it("signs and verifies a valid token", async () => {
|
||||
const token = await signToken({ userId: "user-1", role: "admin" })
|
||||
expect(token).toBeTruthy()
|
||||
expect(typeof token).toBe("string")
|
||||
|
||||
const payload = await verifyToken(token)
|
||||
expect(payload).not.toBeNull()
|
||||
expect(payload!.userId).toBe("user-1")
|
||||
expect(payload!.role).toBe("admin")
|
||||
})
|
||||
|
||||
it("rejects a tampered token", async () => {
|
||||
const token = await signToken({ userId: "user-1", role: "admin" })
|
||||
const tampered = token.slice(0, -5) + "XXXXX"
|
||||
const payload = await verifyToken(tampered)
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("rejects an expired token", async () => {
|
||||
const { SignJWT } = await import("jose")
|
||||
const { getJWTSecret } = await import("@/lib/jwt")
|
||||
const expiredToken = await new SignJWT({ userId: "user-1", role: "admin" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("0s")
|
||||
.sign(getJWTSecret())
|
||||
const payload = await verifyToken(expiredToken)
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("rejects a token with invalid signature", async () => {
|
||||
const { SignJWT } = await import("jose")
|
||||
const wrongSecret = new TextEncoder().encode("different-secret-key-for-signing-purposes-only")
|
||||
const token = await new SignJWT({ userId: "user-1", role: "admin" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("24h")
|
||||
.setIssuedAt()
|
||||
.sign(wrongSecret)
|
||||
const payload = await verifyToken(token)
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for empty token", async () => {
|
||||
const payload = await verifyToken("")
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for garbage token", async () => {
|
||||
const payload = await verifyToken("this.is.not.a.jwt")
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { sanitizeSvg } from "@/lib/sanitize"
|
||||
|
||||
describe("sanitizeSvg", () => {
|
||||
it("strips script tags", () => {
|
||||
const input = `<svg><script>alert('xss')</script><rect width="100" height="100"/></svg>`
|
||||
const result = sanitizeSvg(input)
|
||||
expect(result).not.toContain("script")
|
||||
expect(result).toContain("<svg")
|
||||
expect(result).toContain("<rect")
|
||||
})
|
||||
|
||||
it("strips event handler attributes", () => {
|
||||
const input = `<svg onload="alert(1)"><rect width="100" height="100"/></svg>`
|
||||
const result = sanitizeSvg(input)
|
||||
expect(result).not.toContain("onload")
|
||||
expect(result).toContain("<svg")
|
||||
expect(result).toContain("<rect")
|
||||
})
|
||||
|
||||
it("strips onclick attributes", () => {
|
||||
const input = `<svg><rect onclick="evil()" width="100" height="100"/></svg>`
|
||||
const result = sanitizeSvg(input)
|
||||
expect(result).not.toContain("onclick")
|
||||
expect(result).toContain("<rect")
|
||||
expect(result).toContain('width="100"')
|
||||
})
|
||||
|
||||
it("removes javascript: URLs", () => {
|
||||
const input = `<svg><a href="javascript:alert(1)">click</a></svg>`
|
||||
const result = sanitizeSvg(input)
|
||||
expect(result).not.toContain("javascript:")
|
||||
})
|
||||
|
||||
it("passes through clean SVGs unchanged (except whitespace)", () => {
|
||||
const input = `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>`
|
||||
const result = sanitizeSvg(input)
|
||||
expect(result).toContain("viewBox")
|
||||
expect(result).toContain("<circle")
|
||||
expect(result).toContain('cx="12"')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user