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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/lib/directory/sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from "vitest"

const findFirst = vi.fn()
const update = vi.fn()
const upsert = vi.fn()
const fetchAzureUsers = vi.fn()

vi.mock("@/lib/prisma", () => ({
prisma: {
directoryConnection: {
findFirst: (a: unknown) => findFirst(a),
update: (a: unknown) => update(a),
},
employee: { upsert: (a: unknown) => upsert(a) },
},
}))
vi.mock("./crypto", () => ({ decryptConfig: () => ({}) }))
vi.mock("./azure", () => ({ fetchAzureUsers: () => fetchAzureUsers() }))
vi.mock("./google", () => ({ fetchGoogleUsers: vi.fn() }))
vi.mock("./ldap", () => ({ fetchLDAPUsers: vi.fn() }))
vi.mock("./aws", () => ({ fetchAWSUsers: vi.fn() }))
vi.mock("./okta", () => ({ fetchOktaUsers: vi.fn() }))

import { syncDirectoryConnection } from "./sync"

beforeEach(() => {
vi.clearAllMocks()
findFirst.mockResolvedValue({ id: "c1", type: "AZURE_AD", encryptedConfig: "enc" })
})

describe("syncDirectoryConnection", () => {
it("throws when the connection is not found", async () => {
findFirst.mockResolvedValue(null)
await expect(syncDirectoryConnection("c1", "co1")).rejects.toThrow("Connection not found")
expect(update).not.toHaveBeenCalled()
})

it("marks the connection ERROR and rethrows when the provider fails", async () => {
fetchAzureUsers.mockRejectedValue(new Error("provider down"))
await expect(syncDirectoryConnection("c1", "co1")).rejects.toThrow("provider down")
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "c1" },
data: expect.objectContaining({ status: "ERROR", errorMessage: "provider down" }),
})
)
})

it("upserts each user, marks ACTIVE, and returns the synced count", async () => {
fetchAzureUsers.mockResolvedValue([
{ email: "a@x.com", firstName: "A", lastName: "One" },
{ email: "b@x.com", firstName: "B", lastName: "Two" },
])
const res = await syncDirectoryConnection("c1", "co1")

expect(upsert).toHaveBeenCalledTimes(2)
expect(res).toEqual({ synced: 2 })
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "c1" },
data: expect.objectContaining({ status: "ACTIVE", lastSyncCount: 2, errorMessage: null }),
})
)
})
})
41 changes: 41 additions & 0 deletions src/lib/scan/normalize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from "vitest"
import { normalizeType, parseBreachDate, sleep } from "./normalize"

describe("normalizeType", () => {
it("lowercases, trims, and snake_cases whitespace runs", () => {
expect(normalizeType(" Hashed Password ")).toBe("hashed_password")
expect(normalizeType("Credit Card")).toBe("credit_card")
expect(normalizeType("EMAIL")).toBe("email")
})
})

describe("parseBreachDate", () => {
it("returns epoch for missing input", () => {
expect(parseBreachDate().getTime()).toBe(0)
expect(parseBreachDate(null).getTime()).toBe(0)
expect(parseBreachDate("").getTime()).toBe(0)
})

it("returns epoch for an unparseable date", () => {
expect(parseBreachDate("not-a-date").getTime()).toBe(0)
})

it("parses a valid ISO date", () => {
expect(parseBreachDate("2021-03-15").toISOString()).toBe("2021-03-15T00:00:00.000Z")
})
})

describe("sleep", () => {
it("resolves after the given delay", async () => {
vi.useFakeTimers()
let done = false
const p = sleep(1000).then(() => {
done = true
})
expect(done).toBe(false)
await vi.advanceTimersByTimeAsync(1000)
await p
expect(done).toBe(true)
vi.useRealTimers()
})
})
105 changes: 105 additions & 0 deletions src/lib/scan/runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from "vitest"

const employeeFindMany = vi.fn()
const userFindMany = vi.fn()
const breachUpsert = vi.fn()
const breachRecordCreate = vi.fn()
const alertCreate = vi.fn()
const emailEnabled = vi.fn()
const sendBreachAlert = vi.fn()
const loadActiveWebhooks = vi.fn()
const dispatchWebhooks = vi.fn()

vi.mock("@/lib/prisma", () => ({
prisma: {
employee: { findMany: (a: unknown) => employeeFindMany(a) },
user: { findMany: (a: unknown) => userFindMany(a) },
breach: { upsert: (a: unknown) => breachUpsert(a) },
breachRecord: { create: (a: unknown) => breachRecordCreate(a) },
alert: { create: (a: unknown) => alertCreate(a) },
},
}))
vi.mock("@/lib/directory/crypto", () => ({ decryptConfig: vi.fn() }))
vi.mock("@/lib/email", () => ({
emailEnabled: () => emailEnabled(),
sendBreachAlert: (...a: unknown[]) => sendBreachAlert(...a),
}))
vi.mock("@/lib/webhooks", () => ({
loadActiveWebhooks: () => loadActiveWebhooks(),
dispatchWebhooks: (...a: unknown[]) => dispatchWebhooks(...a),
}))
vi.mock("./registry", () => ({ providerById: vi.fn() }))
vi.mock("./normalize", () => ({ sleep: () => Promise.resolve() }))

import { runScan, severityFor } from "./runner"

describe("severityFor", () => {
it("is CRITICAL with two or more critical data types", () => {
expect(severityFor(["password", "ssn"])).toBe("CRITICAL")
expect(severityFor(["password", "credit_card", "email"])).toBe("CRITICAL")
})
it("is HIGH with exactly one critical type", () => {
expect(severityFor(["password", "email"])).toBe("HIGH")
})
it("is MEDIUM with no critical type", () => {
expect(severityFor(["email", "username"])).toBe("MEDIUM")
expect(severityFor([])).toBe("MEDIUM")
})
})

function provider(lookup: ReturnType<typeof vi.fn>) {
return { provider: { lookup, source: "HIBP", id: "hibp" }, key: "k" } as never
}

describe("runScan", () => {
beforeEach(() => {
vi.clearAllMocks()
emailEnabled.mockReturnValue(false)
loadActiveWebhooks.mockResolvedValue([])
breachRecordCreate.mockResolvedValue({})
alertCreate.mockResolvedValue({})
})

it("persists a new finding and counts it", async () => {
employeeFindMany.mockResolvedValue([
{ id: "e1", email: "e1@x.com", firstName: "E", lastName: "One", breachRecords: [] },
])
breachUpsert.mockResolvedValue({ id: "b1" })
const lookup = vi.fn().mockResolvedValue([
{ name: "Acme", breachDate: new Date(0), dataTypes: ["password"] },
])

const res = await runScan("co1", [provider(lookup)])

expect(res).toEqual({ scanned: 1, newRecords: 1, newAlerts: 1 })
expect(breachRecordCreate).toHaveBeenCalledTimes(1)
expect(alertCreate).toHaveBeenCalledTimes(1)
})

it("skips a breach the employee is already linked to", async () => {
employeeFindMany.mockResolvedValue([
{ id: "e1", email: "e1@x.com", firstName: "E", lastName: "One", breachRecords: [{ breachId: "b1" }] },
])
breachUpsert.mockResolvedValue({ id: "b1" })
const lookup = vi.fn().mockResolvedValue([
{ name: "Acme", breachDate: new Date(0), dataTypes: ["password"] },
])

const res = await runScan("co1", [provider(lookup)])

expect(res.newRecords).toBe(0)
expect(breachRecordCreate).not.toHaveBeenCalled()
})

it("isolates a provider error and keeps scanning", async () => {
employeeFindMany.mockResolvedValue([
{ id: "e1", email: "e1@x.com", firstName: "E", lastName: "One", breachRecords: [] },
])
const lookup = vi.fn().mockRejectedValue(new Error("rate limited"))

const res = await runScan("co1", [provider(lookup)])

expect(res).toEqual({ scanned: 1, newRecords: 0, newAlerts: 0 })
expect(breachUpsert).not.toHaveBeenCalled()
})
})
2 changes: 1 addition & 1 deletion src/lib/scan/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function loadActiveProviders(companyId: string): Promise<ActiveProv
return active
}

function severityFor(dataTypes: string[]): Severity {
export function severityFor(dataTypes: string[]): Severity {
const critical = dataTypes.filter((d) => CRITICAL_TYPES.includes(d)).length
if (critical >= 2) return "CRITICAL"
if (critical === 1) return "HIGH"
Expand Down