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
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run Lint
run: npm run lint

- name: Run Tests
run: npm run test:coverage

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
13 changes: 13 additions & 0 deletions __tests__/__mocks__/prisma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client'
import { beforeEach } from 'vitest'
import { mockReset, DeepMockProxy } from 'vitest-mock-extended'

import { prisma } from '@/lib/prisma'

// We don't need vi.mock here anymore as it is in setup.ts

export const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>

beforeEach(() => {
mockReset(prismaMock)
})
187 changes: 187 additions & 0 deletions __tests__/api/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'
import { POST } from '@/app/api/auth/login/route'
import { prismaMock } from '../__mocks__/prisma'
import { setSessionCookie } from '@/lib/auth'

// Mock dependencies
vi.mock('@/lib/auth', () => ({
setSessionCookie: vi.fn(),
}))

vi.mock('ldap-authentication', () => ({
authenticate: vi.fn(),
}))

describe('Auth API (POST /api/auth/login)', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.MOCK_LDAP = 'true'
process.env.APPROVERS = 'admin,boss'
})

it('should authenticate a user and assign REQUESTER role by default', async () => {
const req = new Request('http://localhost/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username: 'jdoe', password: 'password' }),
})

prismaMock.user.upsert.mockResolvedValue({
id: 'uuid-123',
username: 'jdoe',
role: 'REQUESTER',
createdAt: new Date(),
updatedAt: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)

const response = await POST(req)
const data = await response.json()

expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.user.role).toBe('REQUESTER')
expect(setSessionCookie).toHaveBeenCalled()
})

it('should assign APPROVER role if username is in the APPROVERS list', async () => {
const req = new Request('http://localhost/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username: 'admin', password: 'password' }),
})

prismaMock.user.upsert.mockResolvedValue({
id: 'uuid-admin',
username: 'admin',
role: 'APPROVER',
createdAt: new Date(),
updatedAt: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)

const response = await POST(req)
const data = await response.json()

expect(data.user.role).toBe('APPROVER')
expect(prismaMock.user.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({ role: 'APPROVER' }),
})
)
})

it('should return 401 for invalid credentials in mock mode', async () => {
const req = new Request('http://localhost/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username: 'jdoe', password: 'wrong-password' }),
})

const response = await POST(req)
const data = await response.json()

expect(response.status).toBe(401)
expect(data.success).toBe(false)
})

it('should support real LDAP authentication success', async () => {
process.env.MOCK_LDAP = 'false'
process.env.LDAP_URL = 'ldap://test'
const { authenticate } = await import('ldap-authentication')
;(authenticate as Mock).mockResolvedValue({ sAMAccountName: 'jdoe' })

const req = new Request('http://localhost/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username: 'jdoe', password: 'secret-password' }),
})

// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismaMock.user.upsert.mockResolvedValue({ id: '1', username: 'jdoe', role: 'REQUESTER' } as any)

const response = await POST(req)
const data = await response.json()

expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(authenticate).toHaveBeenCalled()
})

it('should handle real LDAP authentication failure', async () => {
process.env.MOCK_LDAP = 'false'
process.env.LDAP_URL = 'ldap://test'
const { authenticate } = await import('ldap-authentication')
;(authenticate as Mock).mockRejectedValue(new Error('LDAP Connection Failed'))

const req = new Request('http://localhost/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username: 'jdoe', password: 'any-password' }),
})

const response = await POST(req)
const data = await response.json()

expect(response.status).toBe(401)
expect(data.error).toBe('Invalid credentials')
})

it('should handle Active Directory style search bind', async () => {
process.env.MOCK_LDAP = 'false'
process.env.LDAP_URL = 'ldap://test'
process.env.LDAP_BIND_DN = 'cn=admin'
process.env.LDAP_BIND_PASSWORD = 'password'
process.env.LDAP_SEARCH_FILTER = '(uid={{username}})'

const { authenticate } = await import('ldap-authentication')
;(authenticate as Mock).mockResolvedValue({ sAMAccountName: 'jdoe' })

const req = new Request('http://localhost/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username: 'jdoe', password: 'secret-password' }),
})

// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismaMock.user.upsert.mockResolvedValue({ id: '1', username: 'jdoe', role: 'REQUESTER' } as any)

const response = await POST(req)
await response.json()

expect(response.status).toBe(200)
expect(authenticate).toHaveBeenCalledWith(expect.objectContaining({
adminDn: 'cn=admin',
userSearchFilter: '(uid=jdoe)'
}))
})

it('should fallback to default admin approver if APPROVERS env is missing', async () => {
delete process.env.APPROVERS
const req = new Request('http://localhost/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username: 'admin', password: 'password' }),
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismaMock.user.upsert.mockResolvedValue({ id: '1', username: 'admin', role: 'APPROVER' } as any)
const response = await POST(req)
const data = await response.json()
expect(data.user.role).toBe('APPROVER')
})

it('should handle missing LDAP_SEARCH_FILTER', async () => {
process.env.MOCK_LDAP = 'false'
process.env.LDAP_URL = 'ldap://test'
process.env.LDAP_BIND_DN = 'cn=admin'
delete process.env.LDAP_SEARCH_FILTER

const { authenticate } = await import('ldap-authentication')
;(authenticate as Mock).mockResolvedValue({ cn: 'jdoe' })

const req = new Request('http://localhost/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username: 'jdoe', password: 'p' }),
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismaMock.user.upsert.mockResolvedValue({ id: '1', username: 'jdoe', role: 'REQUESTER' } as any)
await POST(req)

expect(authenticate).toHaveBeenCalledWith(expect.not.objectContaining({
userSearchFilter: expect.anything()
}))
})
})
62 changes: 62 additions & 0 deletions __tests__/api/collection-detail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'
import { DELETE, PATCH } from '@/app/api/collections/[id]/route'
import { prismaMock } from '../__mocks__/prisma'
import { getSession } from '@/lib/auth'

interface RouteParams {
params: Promise<{ id: string }>;
}

vi.mock('@/lib/auth', () => ({
getSession: vi.fn(),
}))

describe('Collection Detail API (DELETE/PATCH /api/collections/[id])', () => {
beforeEach(() => {
vi.clearAllMocks()
})

describe('DELETE', () => {
it('should allow owner to delete a collection', async () => {
;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'REQUESTER' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismaMock.requestCollection.findUnique.mockResolvedValue({ id: 'coll-1', creatorId: 'user-1' } as any)

const req = new Request('http://localhost/api/collections/coll-1', { method: 'DELETE' })
const response = await DELETE(req, { params: Promise.resolve({ id: 'coll-1' }) } as RouteParams)

expect(response.status).toBe(200)
expect(prismaMock.requestCollection.delete).toHaveBeenCalled()
})

it('should refuse deletion if user is not the owner', async () => {
;(getSession as Mock).mockResolvedValue({ id: 'user-2', role: 'REQUESTER' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismaMock.requestCollection.findUnique.mockResolvedValue({ id: 'coll-1', creatorId: 'user-1' } as any)

const req = new Request('http://localhost/api/collections/coll-1', { method: 'DELETE' })
const response = await DELETE(req, { params: Promise.resolve({ id: 'coll-1' }) } as RouteParams)

expect(response.status).toBe(403)
})
})

describe('PATCH', () => {
it('should allow owner to update a collection', async () => {
;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'REQUESTER' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismaMock.requestCollection.findUnique.mockResolvedValue({ id: 'coll-1', creatorId: 'user-1' } as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismaMock.requestCollection.update.mockResolvedValue({ id: 'coll-1' } as any)

const req = new Request('http://localhost/api/collections/coll-1', {
method: 'PATCH',
body: JSON.stringify({ name: 'Updated Name' })
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'coll-1' }) } as RouteParams)

expect(response.status).toBe(200)
expect(prismaMock.requestCollection.update).toHaveBeenCalled()
})
})
})
83 changes: 83 additions & 0 deletions __tests__/api/collections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'
import { GET, POST } from '@/app/api/collections/route'
import { prismaMock } from '../__mocks__/prisma'
import { getSession } from '@/lib/auth'

vi.mock('@/lib/auth', () => ({
getSession: vi.fn(),
}))

describe('Collections API', () => {
beforeEach(() => {
vi.clearAllMocks()
})

describe('GET /api/collections', () => {
it('should show global and personal collections to a REQUESTER', async () => {
;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'REQUESTER' })
prismaMock.requestCollection.findMany.mockResolvedValue([])

await GET()

expect(prismaMock.requestCollection.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [
{ isGlobal: true },
{ creatorId: 'user-1' }
]
})
})
)
})

it('should show all collections to an APPROVER', async () => {
;(getSession as Mock).mockResolvedValue({ id: 'boss-1', role: 'APPROVER' })
prismaMock.requestCollection.findMany.mockResolvedValue([])

await GET()

expect(prismaMock.requestCollection.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {}
})
)
})
})

describe('POST /api/collections', () => {
it('should create a new collection', async () => {
;(getSession as Mock).mockResolvedValue({ id: 'user-1', username: 'jdoe' })
const mockRequest = { id: 'coll-1' }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismaMock.requestCollection.create.mockResolvedValue(mockRequest as any)

const req = new Request('http://localhost/api/collections', {
method: 'POST',
body: JSON.stringify({ name: 'My API', url: 'https://test.com', method: 'GET' })
})
const response = await POST(req)
expect(response.status).toBe(200)
expect(prismaMock.requestCollection.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ creatorId: 'user-1', name: 'My API' })
})
)
})
it('should return 500 when creation fails', async () => {
;(getSession as Mock).mockResolvedValue({ id: 'user-1', username: 'jdoe' })
prismaMock.requestCollection.create.mockRejectedValue(new Error('DB constraint failed'))

const req = new Request('http://localhost/api/collections', {
method: 'POST',
body: JSON.stringify({ name: 'Bad API', url: 'https://fail.com', method: 'GET' })
})
const response = await POST(req)
const data = await response.json()

expect(response.status).toBe(500)
expect(data.error).toBe('Failed to save collection')
expect(data.details).toBe('DB constraint failed')
})
})
})
Loading
Loading