diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..66c05c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/__tests__/__mocks__/prisma.ts b/__tests__/__mocks__/prisma.ts new file mode 100644 index 0000000..248ac26 --- /dev/null +++ b/__tests__/__mocks__/prisma.ts @@ -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 + +beforeEach(() => { + mockReset(prismaMock) +}) diff --git a/__tests__/api/auth.test.ts b/__tests__/api/auth.test.ts new file mode 100644 index 0000000..c6fbd84 --- /dev/null +++ b/__tests__/api/auth.test.ts @@ -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() + })) + }) +}) diff --git a/__tests__/api/collection-detail.test.ts b/__tests__/api/collection-detail.test.ts new file mode 100644 index 0000000..671b693 --- /dev/null +++ b/__tests__/api/collection-detail.test.ts @@ -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() + }) + }) +}) diff --git a/__tests__/api/collections.test.ts b/__tests__/api/collections.test.ts new file mode 100644 index 0000000..f3bef43 --- /dev/null +++ b/__tests__/api/collections.test.ts @@ -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') + }) + }) +}) diff --git a/__tests__/api/execute.test.ts b/__tests__/api/execute.test.ts new file mode 100644 index 0000000..e50361d --- /dev/null +++ b/__tests__/api/execute.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' +import { POST } from '@/app/api/requests/[id]/execute/route' +import { prismaMock } from '../__mocks__/prisma' +import { getSession } from '@/lib/auth' + +interface RouteParams { + params: Promise<{ id: string }>; +} + +vi.mock('@/lib/auth', () => ({ + getSession: vi.fn(), +})) + +// Global mock for fetch +const globalFetch = vi.fn() +global.fetch = globalFetch + +describe('Requests API (POST /api/requests/[id]/execute)', () => { + beforeEach(() => { + vi.clearAllMocks() + globalFetch.mockResolvedValue({ + status: 200, + statusText: 'OK', + text: () => Promise.resolve('{"success":true}'), + }) + }) + + it('should allow the requester to execute an APPROVED request', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'REQUESTER' }) + const mockRequest = { + id: 'req-1', + requesterId: 'user-1', + status: 'APPROVED', + method: 'POST', + url: 'https://api.test/webhook', + headers: JSON.stringify({ 'Content-Type': 'application/json' }), + body: '{"foo":"bar"}' + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue(mockRequest as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.update.mockResolvedValue({ ...mockRequest, status: 'EXECUTED' } as any) + + const req = new Request('http://localhost/api/requests/req-1/execute', { method: 'POST' }) + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(200) + expect(globalFetch).toHaveBeenCalledWith('https://api.test/webhook', expect.objectContaining({ + method: 'POST', + body: '{"foo":"bar"}' + })) + expect(prismaMock.httpRequest.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'EXECUTED' }) + }) + ) + }) + + it('should prevent execution if not the requester', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'other-user', role: 'REQUESTER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ requesterId: 'user-1', status: 'APPROVED' } as any) + + const req = new Request('http://localhost/api/requests/req-1/execute', { method: 'POST' }) + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(403) + }) + + it('should prevent execution if not approved', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'REQUESTER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ requesterId: 'user-1', status: 'PENDING' } as any) + + const req = new Request('http://localhost/api/requests/req-1/execute', { method: 'POST' }) + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(400) + }) + + it('should handle execution failure (fetch error)', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'REQUESTER' }) + prismaMock.httpRequest.findUnique.mockResolvedValue({ + id: 'req-1', requesterId: 'user-1', status: 'APPROVED', method: 'GET', url: 'https://broken.api' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + + globalFetch.mockRejectedValue(new Error('Network failure')) + + const req = new Request('http://localhost/api/requests/req-1/execute', { method: 'POST' }) + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(500) + const data = await response.json() + expect(data.error).toBe('Execution failed') + expect(data.details).toBe('Network failure') + }) +}) diff --git a/__tests__/api/reject.test.ts b/__tests__/api/reject.test.ts new file mode 100644 index 0000000..aaa19b9 --- /dev/null +++ b/__tests__/api/reject.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' +import { POST } from '@/app/api/requests/[id]/reject/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('Requests API (POST /api/requests/[id]/reject)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should allow an APPROVER to reject a request', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'approver-id', role: 'APPROVER' }) + const mockRequest = { id: 'req-1', requesterId: 'user-id', status: 'PENDING' } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue(mockRequest as any) + + const req = new Request('http://localhost/api/requests/req-1/reject', { method: 'POST' }) + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(200) + expect(prismaMock.httpRequest.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'REJECTED' }), + }) + ) + }) + + it('should return 400 if request is already processed', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'approver-id', role: 'APPROVER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ status: 'APPROVED' } as any) + + const req = new Request('http://localhost/api/requests/req-1/reject', { method: 'POST' }) + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(400) + }) + + it('should return 401 if user is not an APPROVER', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-id', role: 'REQUESTER' }) + const req = new Request('http://localhost/api/requests/req-1/reject', { method: 'POST' }) + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + expect(response.status).toBe(401) + }) + + it('should return 404 if request is not found', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'boss-id', role: 'APPROVER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue(null as any) + const req = new Request('http://localhost/api/requests/none/reject', { method: 'POST' }) + const response = await POST(req, { params: Promise.resolve({ id: 'none' }) } as RouteParams) + expect(response.status).toBe(404) + }) + + it('should prevent self-rejection', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'my-id', role: 'APPROVER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ requesterId: 'my-id', status: 'PENDING' } as any) + const req = new Request('http://localhost/api/requests/req-1/reject', { method: 'POST' }) + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + expect(response.status).toBe(403) + }) +}) diff --git a/__tests__/api/request-detail.test.ts b/__tests__/api/request-detail.test.ts new file mode 100644 index 0000000..1d436b4 --- /dev/null +++ b/__tests__/api/request-detail.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' +import { GET, PATCH } from '@/app/api/requests/[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('Request Detail API (GET/PATCH /api/requests/[id])', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('GET', () => { + it('should return request if user is the owner', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'REQUESTER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ id: 'req-1', requesterId: 'user-1' } as any) + + const req = new Request('http://localhost/api/requests/req-1') + const response = await GET(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(200) + }) + + it('should return 403 if user is not the owner and not an approver', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-2', role: 'REQUESTER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ id: 'req-1', requesterId: 'user-1' } as any) + + const req = new Request('http://localhost/api/requests/req-1') + const response = await GET(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(403) + }) + }) + + describe('PATCH', () => { + it('should allow owner to update a PENDING request', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'REQUESTER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ id: 'req-1', requesterId: 'user-1', status: 'PENDING' } as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.update.mockResolvedValue({ id: 'req-1', status: 'PENDING' } as any) + + const req = new Request('http://localhost/api/requests/req-1', { + method: 'PATCH', + body: JSON.stringify({ url: 'https://new-url.com' }) + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(200) + expect(prismaMock.httpRequest.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ url: 'https://new-url.com' }) + }) + ) + }) + + it('should prevent update if request is already APPROVED', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'REQUESTER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ id: 'req-1', requesterId: 'user-1', status: 'APPROVED' } as any) + + const req = new Request('http://localhost/api/requests/req-1', { + method: 'PATCH', + body: JSON.stringify({ url: 'https://new-url.com' }) + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(400) + }) + + it('should return 403 if user is not the owner and not an approver', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-2', role: 'REQUESTER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ id: 'req-1', requesterId: 'user-1', status: 'PENDING' } as any) + + const req = new Request('http://localhost/api/requests/req-1', { + method: 'PATCH', + body: JSON.stringify({ url: 'https://evil.com' }) + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(403) + expect(prismaMock.httpRequest.update).not.toHaveBeenCalled() + }) + + it('should handle database errors during update', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'REQUESTER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ id: 'req-1', requesterId: 'user-1', status: 'PENDING' } as any) + prismaMock.httpRequest.update.mockRejectedValue(new Error('DB Error')) + + const req = new Request('http://localhost/api/requests/req-1', { + method: 'PATCH', + body: JSON.stringify({ url: 'https://fail.com' }) + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(500) + }) + }) +}) diff --git a/__tests__/api/request-id.test.ts b/__tests__/api/request-id.test.ts new file mode 100644 index 0000000..40c5659 --- /dev/null +++ b/__tests__/api/request-id.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' +import { GET, PATCH } from '@/app/api/requests/[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('Requests ID API (/[id]/route.ts)', () => { + const mockParams = { params: Promise.resolve({ id: 'req-1' }) } as RouteParams + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('GET', () => { + it('should return 404 if request not found', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'u1' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue(null as any) + const res = await GET(new Request('http://l/1'), mockParams) + expect(res.status).toBe(404) + }) + + it('should return 200 and request data', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'u1' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ id: 'req-1', requesterId: 'u1' } as any) + const res = await GET(new Request('http://l/1'), mockParams) + expect(res.status).toBe(200) + }) + }) + + describe('PATCH', () => { + it('should return 403 if not owner or approver', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'other-user', role: 'REQUESTER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ id: 'req-1', requesterId: 'owner-id', status: 'PENDING' } as any) + const res = await PATCH(new Request('http://l/1', { method: 'PATCH', body: '{}' }), mockParams) + expect(res.status).toBe(403) + }) + + it('should return 400 if not pending', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'owner-id', role: 'REQUESTER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ id: 'req-1', requesterId: 'owner-id', status: 'APPROVED' } as any) + const res = await PATCH(new Request('http://l/1', { method: 'PATCH', body: '{}' }), mockParams) + expect(res.status).toBe(400) + }) + }) +}) diff --git a/__tests__/api/requests-basic.test.ts b/__tests__/api/requests-basic.test.ts new file mode 100644 index 0000000..4a4037e --- /dev/null +++ b/__tests__/api/requests-basic.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' +import { GET, POST } from '@/app/api/requests/route' +import { prismaMock } from '../__mocks__/prisma' +import { getSession } from '@/lib/auth' + +vi.mock('@/lib/auth', () => ({ + getSession: vi.fn(), +})) + +describe('Requests API (Basic)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('GET /api/requests', () => { + it('should return 401 if not authenticated', async () => { + ;(getSession as Mock).mockResolvedValue(null) + const req = new Request('http://localhost/api/requests') + const response = await GET(req) + expect(response.status).toBe(401) + }) + + it('should list only user requests for a REQUESTER', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'REQUESTER' }) + prismaMock.httpRequest.findMany.mockResolvedValue([]) + + const req = new Request('http://localhost/api/requests') + await GET(req) + + expect(prismaMock.httpRequest.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ requesterId: 'user-1' }) + }) + ) + }) + + it('should list all requests for an APPROVER', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'boss-1', role: 'APPROVER' }) + prismaMock.httpRequest.findMany.mockResolvedValue([]) + + const req = new Request('http://localhost/api/requests') + await GET(req) + + expect(prismaMock.httpRequest.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.not.objectContaining({ requesterId: 'boss-1' }) + }) + ) + }) + + it('should apply search filters when "q" is provided', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-1', role: 'APPROVER' }) + prismaMock.httpRequest.findMany.mockResolvedValue([]) + + const req = new Request('http://localhost/api/requests?q=target-url') + await GET(req) + + expect(prismaMock.httpRequest.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([ + expect.objectContaining({ url: expect.objectContaining({ contains: 'target-url' }) }) + ]) + }) + }) + ) + }) + }) + + describe('POST /api/requests', () => { + it('should create a new pending request', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-1', username: 'jdoe' }) + const mockRequest = { id: 'new-id', status: 'PENDING' } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.create.mockResolvedValue(mockRequest as any) + + const req = new Request('http://localhost/api/requests', { + method: 'POST', + body: JSON.stringify({ method: 'GET', url: 'https://api.com' }) + }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.request.status).toBe('PENDING') + expect(prismaMock.httpRequest.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ requesterId: 'user-1', method: 'GET' }) + }) + ) + }) + }) +}) diff --git a/__tests__/api/requests.test.ts b/__tests__/api/requests.test.ts new file mode 100644 index 0000000..91b94ea --- /dev/null +++ b/__tests__/api/requests.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' +import { POST } from '@/app/api/requests/[id]/approve/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('Request Approval API (POST /api/requests/[id]/approve)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should allow an approver to approve a PENDING request', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'approver-id', username: 'boss', role: 'APPROVER' }) + + const mockRequest = { id: 'req-1', requesterId: 'user-1', status: 'PENDING' } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue(mockRequest as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.update.mockResolvedValue({ ...mockRequest, status: 'APPROVED' } as any) + + const req = new Request('http://localhost/api/requests/req-1/approve', { + method: 'POST', + }) + + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + await response.json() + + expect(response.status).toBe(200) + expect(prismaMock.httpRequest.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'APPROVED', approverId: 'approver-id' }), + }) + ) + }) + + it('should prevent a requester from approving their own request', async () => { + // 1. Mock Session: Current user is the Requester (even if they have APPROVER role) + ;(getSession as Mock).mockResolvedValue({ id: 'my-id', username: 'jdoe', role: 'APPROVER' }) + + // 2. Mock DB: Request id match session id + const mockRequest = { id: 'req-1', requesterId: 'my-id', status: 'PENDING' } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue(mockRequest as any) + + const req = new Request('http://localhost/api/requests/req-1/approve', { + method: 'POST', + }) + + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('cannot approve your own request') + }) + + it('should return 401 if user is not an APPROVER', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'user-id', username: 'jdoe', role: 'REQUESTER' }) + + const req = new Request('http://localhost/api/requests/req-1/approve', { + method: 'POST', + }) + + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + expect(response.status).toBe(401) // Correctly matches the route handler's unauthorized response + }) + + it('should handle request not found', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'approver-id', role: 'APPROVER' }) + prismaMock.httpRequest.findUnique.mockResolvedValue(null) + + const req = new Request('http://localhost/api/requests/ghost-1/approve', { method: 'POST' }) + const response = await POST(req, { params: Promise.resolve({ id: 'ghost-1' }) } as RouteParams) + + expect(response.status).toBe(404) + }) + + it('should handle database errors during approval', async () => { + ;(getSession as Mock).mockResolvedValue({ id: 'approver-id', role: 'APPROVER' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaMock.httpRequest.findUnique.mockResolvedValue({ id: 'req-1', status: 'PENDING', requesterId: 'u1' } as any) + prismaMock.httpRequest.update.mockRejectedValue(new Error('DB Error')) + + const req = new Request('http://localhost/api/requests/req-1/approve', { method: 'POST' }) + const response = await POST(req, { params: Promise.resolve({ id: 'req-1' }) } as RouteParams) + + expect(response.status).toBe(500) + }) +}) diff --git a/__tests__/components/Dashboard.test.tsx b/__tests__/components/Dashboard.test.tsx new file mode 100644 index 0000000..241d2f4 --- /dev/null +++ b/__tests__/components/Dashboard.test.tsx @@ -0,0 +1,90 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest' +import Dashboard from '@/app/page' +import { SWRConfig } from 'swr' + +// Mock Next.js Navigation +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +describe('Dashboard Component - Final Stability', () => { + const mockUser = { id: 'u1', username: 'admin', role: 'APPROVER' } + const mockRequests = [ + { id: 'req1', method: 'GET', url: 'h1', status: 'PENDING', requester: { username: 'o' }, createdAt: new Date().toISOString() }, + { id: 'req2', method: 'POST', url: 'h2', status: 'EXECUTED', response: JSON.stringify({ status: 200 }), createdAt: new Date().toISOString() } + ] + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + global.fetch = vi.fn() + global.sessionStorage = { setItem: vi.fn(), getItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), length: 0, key: vi.fn() } as unknown as Storage + }) + + afterEach(() => { vi.useRealTimers() }) + + const renderDashboard = () => { + return render( + new Map(), dedupingInterval: 0, shouldRetryOnError: false }}> + + + ) + } + + const mockSuccess = (user: unknown, reqs: unknown[]) => { + ;(global.fetch as unknown as { mockImplementation: (fn: (url: string) => Promise) => void }).mockImplementation((url: string) => { + if (url.includes('/api/auth/me')) return Promise.resolve({ ok: true, json: () => Promise.resolve({ user }) }) + if (url.includes('/api/requests')) return Promise.resolve({ ok: true, json: () => Promise.resolve({ requests: reqs }) }) + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }) + }) + } + + it('covers interactive success paths, logout and error handling', async () => { + mockSuccess(mockUser, mockRequests) + renderDashboard() + + const row = await screen.findByText(/req1/i) + expect(row).toBeDefined() + + // 1. Success Action + const approveBtn = screen.getByRole('button', { name: /Approve/i }) + fireEvent.click(approveBtn) + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/approve'), expect.any(Object))) + + // 2. Failed Action + ;(global.fetch as Mock).mockImplementationOnce(() => Promise.resolve({ ok: false, json: () => Promise.resolve({ error: 'Failed' }) })) + fireEvent.click(screen.getByRole('button', { name: /Reject/i })) + await waitFor(() => expect(screen.getByText(/Failed: Failed/i)).toBeDefined()) + + // 3. Clone & Logout + fireEvent.click(screen.getAllByRole('button', { name: /Clone/i })[0]) + expect(mockPush).toHaveBeenCalledWith('/create') + + fireEvent.click(screen.getByRole('button', { name: /Logout/i })) + expect(global.fetch).toHaveBeenCalledWith('/api/auth/logout', expect.any(Object)) + + // 4. Keyboard Esc + fireEvent.keyDown(window, { key: 'Escape' }) + }) + + it('covers empty states and template saving', async () => { + mockSuccess(mockUser, []) + const { unmount } = renderDashboard() + await screen.findByText(/No audit requests pending review/i) + unmount() + + // Template save + mockSuccess(mockUser, mockRequests) + renderDashboard() + await screen.findByText(/req1/i) + fireEvent.click(screen.getAllByRole('button', { name: /Save/i })[0]) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.change(screen.getByPlaceholderText(/Production Cache Purge/i), { target: { value: 'T' } }) + fireEvent.click(screen.getByText('Confirm Save')) + expect(global.fetch).toHaveBeenCalledWith('/api/collections', expect.objectContaining({ method: 'POST' })) + }) +}) diff --git a/__tests__/components/Inspector.test.tsx b/__tests__/components/Inspector.test.tsx new file mode 100644 index 0000000..084c783 --- /dev/null +++ b/__tests__/components/Inspector.test.tsx @@ -0,0 +1,111 @@ +import { render, screen, fireEvent, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Inspector from '@/components/Inspector' + +describe('Inspector Component', () => { + const mockRequest = { + id: 'req-123', + method: 'POST', + url: 'https://api.example.com?q=test', + status: 'EXECUTED', + requesterId: 'user-1', + createdAt: new Date().toISOString(), + headers: JSON.stringify({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ key: 'val' }), + response: JSON.stringify({ status: 200, body: '{"ok":true}' }), + requester: { username: 'john_doe' }, + approver: { username: 'admin_user' }, + approvedAt: new Date().toISOString() + } + + const mockOnClose = vi.fn() + const mockOnSave = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }) + }) + + it('renders all tabs and content correctly', () => { + render() + + expect(screen.getByText(/Inspection Detail/i)).toBeDefined() + expect(screen.getByText('POST')).toBeDefined() + + expect(screen.getByText(/john_doe/)).toBeDefined() + expect(screen.getByText(/Operator/i)).toBeDefined() + expect(screen.getByText(/Verifier/i)).toBeDefined() + + // Params Tab (Default) + expect(screen.getByText('Params')).toBeDefined() + // There are two "1" badges (Params and Headers) + expect(screen.getAllByText('1')).toHaveLength(2) + expect(screen.getByText('test')).toBeDefined() + + // Headers Tab + fireEvent.click(screen.getByText('Headers')) + expect(screen.getByText('application/json')).toBeDefined() + + // Response Tab + fireEvent.click(screen.getByText('Response')) + expect(screen.getByText('2')).toBeDefined() // Count for status & body + expect(screen.getByText(/200/)).toBeDefined() + }) + + it('handles copy to clipboard', async () => { + vi.useFakeTimers() + render() + + const copyBtn = screen.getByLabelText('Copy URL') + fireEvent.click(copyBtn) + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockRequest.url) + expect(screen.getByText(/Copied!/i)).toBeDefined() + + act(() => { + vi.advanceTimersByTime(2100) + }) + + expect(screen.getByText(/Copy Endpoint/i)).toBeDefined() + vi.useRealTimers() + }) + + it('toggles Pretty/Raw response views', () => { + render() + fireEvent.click(screen.getByText('Response')) + + expect(screen.getByText('PRETTY')).toBeDefined() + fireEvent.click(screen.getByText('RAW')) + // Text should still be there but formatted differently (not easily assertable by text alone, but covers branch) + }) + + it('triggers onSaveTemplate and onClose', async () => { + vi.useFakeTimers() + render() + + fireEvent.click(screen.getByText(/Save Template/i)) + expect(mockOnSave).toHaveBeenCalledWith(mockRequest) + + fireEvent.click(screen.getByLabelText('Close')) + expect(screen.getByRole('dialog').className).toContain('animate-fade-out') + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(mockOnClose).toHaveBeenCalled() + vi.useRealTimers() + }) + + it('handles empty/malformed data gracefully', () => { + const badReq = { ...mockRequest, url: 'https://api.example.com', headers: '{', body: '', response: '', status: 'PENDING' } + render() + + expect(screen.getByText(/No values provided/i)).toBeDefined() + expect(screen.getAllByText(/No body provided/i)).toBeDefined() + + fireEvent.click(screen.getByText('Response')) + expect(screen.getByText(/No execution data available/i)).toBeDefined() + }) +}) diff --git a/__tests__/components/RequestRow.test.tsx b/__tests__/components/RequestRow.test.tsx new file mode 100644 index 0000000..ddf00e1 --- /dev/null +++ b/__tests__/components/RequestRow.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import RequestRow from '@/components/RequestRow' + +describe('RequestRow Component', () => { + const mockUser = { id: 'u1', username: 'admin', role: 'APPROVER' } + const mockRequest = { + id: 'req123-abc', + method: 'GET', + url: 'http://example.com', + status: 'PENDING', + requesterId: 'u2', + requester: { username: 'user2' }, + createdAt: new Date().toISOString() + } + + const mockHandlers = { + onAction: vi.fn(), + onClone: vi.fn(), + onSave: vi.fn(), + onSelect: vi.fn() + } + + it('renders request details correctly', () => { + render(
) + + expect(screen.getByText('req123')).toBeDefined() + expect(screen.getByText('GET')).toBeDefined() + expect(screen.getByText('PENDING')).toBeDefined() + expect(screen.getByText('user2')).toBeDefined() + }) + + it('shows Approve/Reject buttons for Approvers on others requests', () => { + render(
) + + expect(screen.getByText(/Approve/i)).toBeDefined() + expect(screen.getByText(/Reject/i)).toBeDefined() + }) + + it('shows Execute button for owners on approved requests', () => { + const approvedReq = { ...mockRequest, status: 'APPROVED', requesterId: 'u1' } + render(
) + + expect(screen.getByText(/Execute/i)).toBeDefined() + }) + + it('triggers onSelect when clicking the row', () => { + render(
) + fireEvent.click(screen.getByText('req123')) + expect(mockHandlers.onSelect).toHaveBeenCalledWith(mockRequest) + }) + + it('triggers onClone and onSave', () => { + render(
) + fireEvent.click(screen.getByLabelText('Clone')) + expect(mockHandlers.onClone).toHaveBeenCalled() + fireEvent.click(screen.getByLabelText('Save')) + expect(mockHandlers.onSave).toHaveBeenCalled() + }) + + it('handles HTTP code rendering', () => { + const executedReq = { ...mockRequest, status: 'EXECUTED', response: JSON.stringify({ status: 200 }) } + render(
) + expect(screen.getByText('200')).toBeDefined() + }) + + it('renders "-" for non-executed requests', () => { + render(
) + expect(screen.getByText('-')).toBeDefined() + }) + + it('renders "Err" for malformed response', () => { + const badReq = { ...mockRequest, status: 'EXECUTED', response: '{' } + render(
) + expect(screen.getByText('Err')).toBeDefined() + }) +}) diff --git a/__tests__/components/TemplateModal.test.tsx b/__tests__/components/TemplateModal.test.tsx new file mode 100644 index 0000000..4432d20 --- /dev/null +++ b/__tests__/components/TemplateModal.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, fireEvent, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import TemplateModal from '@/components/TemplateModal' + +describe('TemplateModal Component', () => { + const mockOnClose = vi.fn() + const mockOnSave = vi.fn().mockResolvedValue(undefined) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders correctly', () => { + render() + expect(screen.getByText(/Save as Blueprint/i)).toBeDefined() + }) + + it('validates input before saving', async () => { + render() + const saveBtn = screen.getByText('Confirm Save') + + expect(saveBtn.hasAttribute('disabled')).toBe(true) + + fireEvent.change(screen.getByPlaceholderText(/Production Cache Purge/i), { target: { value: 'My Template' } }) + expect(saveBtn.hasAttribute('disabled')).toBe(false) + + await act(async () => { + fireEvent.click(saveBtn) + }) + + expect(mockOnSave).toHaveBeenCalledWith('My Template', false) + }) + + it('handles global toggle', async () => { + render() + + fireEvent.change(screen.getByPlaceholderText(/Production Cache Purge/i), { target: { value: 'Global Template' } }) + fireEvent.click(screen.getByRole('checkbox')) + + await act(async () => { + fireEvent.click(screen.getByText('Confirm Save')) + }) + + expect(mockOnSave).toHaveBeenCalledWith('Global Template', true) + }) + + it('closes on cancel', () => { + render() + fireEvent.click(screen.getByText('Cancel')) + expect(mockOnClose).toHaveBeenCalled() + }) +}) diff --git a/__tests__/setup.ts b/__tests__/setup.ts new file mode 100644 index 0000000..5646fd4 --- /dev/null +++ b/__tests__/setup.ts @@ -0,0 +1,39 @@ +import * as matchers from '@testing-library/jest-dom/matchers' +import { expect, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaClient } from '@prisma/client' + +expect.extend(matchers) + +// Global Prisma Mock +vi.mock('@/lib/prisma', () => ({ + __esModule: true, + prisma: mockDeep(), +})) + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + back: vi.fn(), + }), + useSearchParams: () => ({ + get: vi.fn(), + }), + usePathname: () => '', +})) + +// Global mock for logger to keep test output clean +vi.mock('@/lib/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})) + +// Mock static assets like images/SVGs +vi.mock('/logo.svg', () => 'logo-mock') diff --git a/__tests__/utils/db-config.test.ts b/__tests__/utils/db-config.test.ts new file mode 100644 index 0000000..f2e3b95 --- /dev/null +++ b/__tests__/utils/db-config.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' +import { transformSchema, configure, run, ConfigDeps } from '../../scripts/configure-db' + +describe('Database Configurator (Injection Pattern)', () => { + const baseSchema = ` +generator client { + provider = "sqlite" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model HttpRequest { + id String @id @default(uuid()) + headers String? + body String? + response String? + url String +} + `.trim() + + let mockDeps: ConfigDeps + + beforeEach(() => { + mockDeps = { + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue(baseSchema), + writeFileSync: vi.fn(), + execSync: vi.fn().mockReturnValue(Buffer.from('')), + log: vi.fn(), + error: vi.fn(), + exit: vi.fn().mockImplementation(() => { throw new Error('exit') }), + } + }) + + describe('transformSchema', () => { + it('should transform SQLite to MySQL and inject native attributes', () => { + const result = transformSchema(baseSchema, 'mysql') + expect(result).toContain('provider = "mysql"') + expect(result).toContain('body String? @db.LongText') + expect(result).toContain('url String @db.VarChar(1000)') + }) + + it('should transform SQLite to PostgreSQL', () => { + const result = transformSchema(baseSchema, 'postgresql') + expect(result).toContain('provider = "postgresql"') + expect(result).not.toContain('@db.LongText') + }) + }) + + describe('configure()', () => { + it('should read, transform, and write schema files', async () => { + await configure('postgresql', mockDeps) + expect(mockDeps.writeFileSync).toHaveBeenCalled() + expect(mockDeps.execSync).toHaveBeenCalledWith(expect.stringContaining('prisma generate'), expect.any(Object)) + }) + + it('should handle missing schema file', async () => { + ;(mockDeps.existsSync as Mock).mockReturnValue(false) + await expect(configure('sqlite', mockDeps)).rejects.toThrow('schema.prisma not found') + expect(mockDeps.error).toHaveBeenCalledWith(expect.stringContaining('Error during configuration')) + }) + }) + + describe('run()', () => { + it('should normalized postgres into postgresql', async () => { + await run(['postgres'], mockDeps) + expect(mockDeps.log).toHaveBeenCalledWith(expect.stringContaining('POSTGRESQL')) + }) + + it('should handle invalid arguments', () => { + expect(() => run(['invalid'], mockDeps)).toThrow('exit') + expect(mockDeps.exit).toHaveBeenCalledWith(1) + expect(mockDeps.error).toHaveBeenCalledWith(expect.stringContaining('Usage:')) + }) + + it('should handle missing arguments', () => { + expect(() => run([], mockDeps)).toThrow('exit') + expect(mockDeps.exit).toHaveBeenCalledWith(1) + }) + }) +}) diff --git a/__tests__/utils/utils.test.ts b/__tests__/utils/utils.test.ts new file mode 100644 index 0000000..4255b8f --- /dev/null +++ b/__tests__/utils/utils.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { formatDate, getStatusColor, getMethodColor, getHttpStatusColor } from '@/lib/utils' + +describe('UI Utilities', () => { + describe('formatDate', () => { + it('formats a date string correctly', () => { + const date = new Date('2024-01-01T12:00:00Z') + const result = formatDate(date) + expect(result).toContain('Jan 1') + }) + + it('returns Never for empty input', () => { + expect(formatDate('')).toBe('Never') + }) + }) + + describe('getStatusColor', () => { + it('returns correct colors for each status', () => { + expect(getStatusColor('PENDING')).toContain('#ff9800') + expect(getStatusColor('APPROVED')).toContain('#2196f3') + expect(getStatusColor('EXECUTED')).toContain('#4caf50') + expect(getStatusColor('REJECTED')).toContain('#f44336') + expect(getStatusColor('UNKNOWN')).toContain('zinc-500') + }) + }) + + describe('getMethodColor', () => { + it('returns correct colors for HTTP methods', () => { + expect(getMethodColor('GET')).toContain('#4caf50') + expect(getMethodColor('POST')).toContain('#2196f3') + expect(getMethodColor('PUT')).toContain('#ff9800') + expect(getMethodColor('PATCH')).toContain('#9c27b0') + expect(getMethodColor('DELETE')).toContain('#f44336') + expect(getMethodColor('HEAD')).toContain('zinc-500') + }) + + it('handles lowercase/missing methods', () => { + expect(getMethodColor('get')).toContain('#4caf50') + expect(getMethodColor(undefined as unknown as string)).toContain('zinc-500') + }) + }) + + describe('getHttpStatusColor', () => { + it('returns green for 2xx', () => { + expect(getHttpStatusColor(200)).toContain('#4caf50') + expect(getHttpStatusColor(201)).toContain('#4caf50') + }) + it('returns red for 4xx/5xx', () => { + expect(getHttpStatusColor(400)).toContain('#f44336') + expect(getHttpStatusColor(500)).toContain('#f44336') + }) + it('returns gray for others', () => { + expect(getHttpStatusColor(302)).toContain('zinc-500') + }) + }) +}) diff --git a/next.config.ts b/next.config.ts index 1d53e8b..ff21ebe 100644 --- a/next.config.ts +++ b/next.config.ts @@ -16,7 +16,6 @@ const allowedExternalOrigins = process.env.ALLOWED_ORIGINS const nextConfig: NextConfig = { /* config options here */ - // @ts-ignore -- Custom/localized typing allowedDevOrigins: [...validIps, ...allowedExternalOrigins], // Note: If your login is failing due to Next.js 14+ Server Actions, diff --git a/package-lock.json b/package-lock.json index cac8ac0..54a3cf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "forseti-project", + "name": "heimdall", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "forseti-project", + "name": "heimdall", "version": "0.1.0", "dependencies": { "@prisma/client": "^5.21.1", @@ -19,17 +19,31 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.4", "dotenv": "^17.4.1", "eslint": "^9", "eslint-config-next": "16.2.2", + "jsdom": "^29.0.2", "prisma": "^5.21.1", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.4", + "vitest-mock-extended": "^4.0.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -43,6 +57,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.10", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz", + "integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz", + "integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -235,6 +288,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -283,6 +346,169 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -460,6 +686,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1233,6 +1477,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@prisma/client": { "version": "5.21.1", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.21.1.tgz", @@ -1301,66 +1555,10 @@ "@prisma/debug": "5.21.1" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "cpu": [ "arm64" ], @@ -1371,13 +1569,13 @@ "android" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "cpu": [ "arm64" ], @@ -1388,13 +1586,13 @@ "darwin" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "cpu": [ "x64" ], @@ -1405,13 +1603,13 @@ "darwin" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "cpu": [ "x64" ], @@ -1422,13 +1620,13 @@ "freebsd" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "cpu": [ "arm" ], @@ -1439,13 +1637,13 @@ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "cpu": [ "arm64" ], @@ -1456,13 +1654,13 @@ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "cpu": [ "arm64" ], @@ -1473,15 +1671,15 @@ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "cpu": [ - "x64" + "ppc64" ], "dev": true, "license": "MIT", @@ -1490,15 +1688,15 @@ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", "cpu": [ - "x64" + "s390x" ], "dev": true, "license": "MIT", @@ -1507,7 +1705,353 @@ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { @@ -1588,6 +2132,93 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1599,6 +2230,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2214,6 +2871,176 @@ "win32" ] }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", + "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.4", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.4", + "vitest": "4.1.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2254,6 +3081,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2447,6 +3285,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2454,6 +3302,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2519,6 +3386,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -2657,6 +3534,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2742,6 +3629,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2756,6 +3664,20 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2828,6 +3750,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2903,6 +3832,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "17.4.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", @@ -2959,6 +3896,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -3077,6 +4027,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3556,6 +4513,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3566,6 +4533,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4027,6 +5004,26 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4064,6 +5061,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4349,6 +5356,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4508,6 +5522,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -4565,6 +5618,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5003,6 +6107,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5013,6 +6128,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5023,6 +6179,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5047,6 +6210,16 @@ "node": ">=8.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5348,6 +6521,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5429,6 +6613,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5456,6 +6653,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5524,6 +6728,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prisma": { "version": "5.21.1", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.21.1.tgz", @@ -5615,6 +6857,20 @@ "dev": true, "license": "MIT" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5659,6 +6915,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -5714,6 +6980,47 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5793,6 +7100,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6015,6 +7335,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6031,6 +7358,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6174,6 +7515,19 @@ "node": ">=4" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6249,6 +7603,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -6270,6 +7631,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6318,6 +7696,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6331,6 +7739,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6344,6 +7778,21 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-essentials": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.1.1.tgz", + "integrity": "sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6524,6 +7973,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6616,6 +8075,262 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest-mock-extended": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-4.0.0.tgz", + "integrity": "sha512-m2FmH8JYfxzZoLsHuhXRY+Pv++a3zd91HYpSz81tpRLEHbtFkEL2QcWvJowucWuNTirzQURKfWbJJSXbYqkTsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": ">=10.0.0" + }, + "peerDependencies": { + "typescript": "3.x || 4.x || 5.x || 6.x", + "vitest": ">=4.0.0" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6721,6 +8436,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6731,6 +8463,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index f52b10a..0ba41bd 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "lint": "eslint", "db:sqlite": "npx --yes tsx scripts/configure-db.ts sqlite", "db:mysql": "npx --yes tsx scripts/configure-db.ts mysql", - "db:postgres": "npx --yes tsx scripts/configure-db.ts postgresql" + "db:postgres": "npx --yes tsx scripts/configure-db.ts postgresql", + "test": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@prisma/client": "^5.21.1", @@ -23,14 +25,21 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.4", "dotenv": "^17.4.1", "eslint": "^9", "eslint-config-next": "16.2.2", + "jsdom": "^29.0.2", "prisma": "^5.21.1", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.4", + "vitest-mock-extended": "^4.0.0" } -} \ No newline at end of file +} diff --git a/scripts/configure-db.ts b/scripts/configure-db.ts index 6dd8f57..4ba07a5 100644 --- a/scripts/configure-db.ts +++ b/scripts/configure-db.ts @@ -1,86 +1,118 @@ import * as fs from 'fs'; import * as path from 'path'; -import { execSync } from 'child_process'; +import { execSync as nodeExecSync } from 'child_process'; + +/** + * Dependency injection for testability + */ +export interface ConfigDeps { + existsSync: (path: string) => boolean; + readFileSync: (path: string, encoding: 'utf8') => string; + writeFileSync: (path: string, content: string) => void; + execSync: (command: string, options: Record) => Buffer; + log: (message: string) => void; + error: (message: string) => void; + exit: (code: number) => void; +} -const target = process.argv[2]; +export const defaultDeps: ConfigDeps = { + existsSync: fs.existsSync, + readFileSync: (p, e) => fs.readFileSync(p, e), + writeFileSync: fs.writeFileSync, + execSync: nodeExecSync, + log: console.log, + error: console.error, + exit: (code) => process.exit(code), +}; + +// Primary logic for schema transformation +export function transformSchema(content: string, prismaProvider: string): string { + let newContent = content; + + // 1. Repair Generator (Ensure it's always prisma-client-js) + newContent = newContent.replace(/generator\s+client\s+{[\s\S]*?}/, (match) => { + return match.replace(/provider\s*=\s*"[^"]*"/, 'provider = "prisma-client-js"'); + }); + + // 2. Change Datasource Provider + newContent = newContent.replace(/datasource\s+db\s+{[\s\S]*?}/, (match) => { + return match.replace(/provider\s*=\s*"[^"]*"/, `provider = "${prismaProvider}"`); + }); + + // 3. Clear existing native attributes to start fresh + newContent = newContent.replace(/\s*@db\.[a-zA-Z0-9()]*/g, ''); + + // 4. Inject Provider-specific attributes + if (prismaProvider === 'mysql') { + const longTextFields = ['headers', 'body', 'response']; + longTextFields.forEach(field => { + const fieldRegex = new RegExp(`(\\b${field}\\s+String\\??)`, 'g'); + newContent = newContent.replace(fieldRegex, `$1 @db.LongText`); + }); -if (!target || !['sqlite', 'mysql', 'postgresql', 'postgres'].includes(target)) { - console.error('Usage: ts-node scripts/configure-db.ts [sqlite|mysql|postgresql]'); - process.exit(1); -} + newContent = newContent.replace(/(\burl\s+String\??)/g, `$1 @db.VarChar(1000)`); + } -// Normalize provider name for Prisma -const prismaProvider = (target === 'postgres') ? 'postgresql' : target; + return newContent; +} const ROOT_DIR = path.join(__dirname, '..'); const PRISMA_DIR = path.join(ROOT_DIR, 'prisma'); const SCHEMA_FILE = path.join(PRISMA_DIR, 'schema.prisma'); -async function configure() { - console.log(`\x1b[36mConsolidating and Configuring Heimdall for ${prismaProvider.toUpperCase()}...\x1b[0m`); +export async function configure(prismaProvider: string, deps: ConfigDeps = defaultDeps) { + deps.log(`\x1b[36mConsolidating and Configuring Heimdall for ${prismaProvider.toUpperCase()}...\x1b[0m`); try { - if (!fs.existsSync(SCHEMA_FILE)) { + if (!deps.existsSync(SCHEMA_FILE)) { throw new Error('schema.prisma not found. Please ensure the file exists.'); } - let content = fs.readFileSync(SCHEMA_FILE, 'utf8'); - - // 1. Repair Generator (Ensure it's always prisma-client-js) - console.log('Ensuring generator provider is correct...'); - content = content.replace(/generator\s+client\s+{[\s\S]*?}/, (match) => { - return match.replace(/provider\s*=\s*"[^"]*"/, 'provider = "prisma-client-js"'); - }); - - // 2. Change Datasource Provider - console.log(`Updating datasource provider to ${prismaProvider}...`); - content = content.replace(/datasource\s+db\s+{[\s\S]*?}/, (match) => { - return match.replace(/provider\s*=\s*"[^"]*"/, `provider = "${prismaProvider}"`); - }); + let content = deps.readFileSync(SCHEMA_FILE, 'utf8'); - // 3. Clear existing native attributes to start fresh - content = content.replace(/\s*@db\.[a-zA-Z0-9()]*/g, ''); - - // 4. Inject Provider-specific attributes - if (prismaProvider === 'mysql') { - console.log('Injecting MySQL native type attributes (LongText, VarChar)...'); - - const longTextFields = ['headers', 'body', 'response']; - longTextFields.forEach(field => { - const fieldRegex = new RegExp(`(\\b${field}\\s+String\\??)`, 'g'); - content = content.replace(fieldRegex, `$1 @db.LongText`); - }); - - content = content.replace(/(\burl\s+String\??)/g, `$1 @db.VarChar(1000)`); - } else if (prismaProvider === 'postgresql') { - console.log('Injecting PostgreSQL optimizations (optional @db.Text)...'); - // In Postgres, String defaults to text, but we can be explicit if needed. - // We'll leave it clean as Postgres 'text' handles up to 1GB natively. - } + // Perform transformation + content = transformSchema(content, prismaProvider); // 5. Write back to schema.prisma - fs.writeFileSync(SCHEMA_FILE, content); + deps.writeFileSync(SCHEMA_FILE, content); // 6. Regenerate Prisma Client - console.log('Regenerating Prisma Client...'); - execSync('npx prisma generate', { stdio: 'inherit', cwd: ROOT_DIR }); + deps.log('Regenerating Prisma Client...'); + deps.execSync('npx prisma generate', { stdio: 'inherit', cwd: ROOT_DIR }); - console.log('\n\x1b[32mSUCCESS: Database consolidated and configured.\x1b[0m'); - console.log('\x1b[34mNOTE: schema.prisma is now your single source of truth.\x1b[0m'); + deps.log('\n\x1b[32mSUCCESS: Database consolidated and configured.\x1b[0m'); + deps.log('\x1b[34mNOTE: schema.prisma is now your single source of truth.\x1b[0m'); if (prismaProvider === 'mysql' || prismaProvider === 'postgresql') { - console.log('\x1b[33m\nNEXT STEPS:\x1b[0m'); - console.log(`1. Update DATABASE_URL in .env to your ${prismaProvider.toUpperCase()} connection string.`); - console.log('2. Run: npx prisma migrate dev (to sync schema and create tables)'); + deps.log('\x1b[33m\nNEXT STEPS:\x1b[0m'); + deps.log(`1. Update DATABASE_URL in .env to your ${prismaProvider.toUpperCase()} connection string.`); + deps.log('2. Run: npx prisma migrate dev (to sync schema and create tables)'); } else { - console.log('\x1b[33m\nNEXT STEPS:\x1b[0m'); - console.log('1. Ensure DATABASE_URL in .env is file:./dev.db'); - console.log('2. Run: npx prisma db push'); + deps.log('\x1b[33m\nNEXT STEPS:\x1b[0m'); + deps.log('1. Ensure DATABASE_URL in .env is file:./dev.db'); + deps.log('2. Run: npx prisma db push'); } - } catch (err: any) { - console.error(`\x1b[31mError during configuration: ${err.message}\x1b[0m`); - process.exit(1); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + deps.error(`\x1b[31mError during configuration: ${message}\x1b[0m`); + throw err; } } -configure(); +export function run(args: string[], deps: ConfigDeps = defaultDeps) { + const target = args[0]; + if (!target || !['sqlite', 'mysql', 'postgresql', 'postgres'].includes(target)) { + deps.error('Usage: ts-node scripts/configure-db.ts [sqlite|mysql|postgresql]'); + deps.exit(1); + return; + } + const prismaProvider = (target === 'postgres') ? 'postgresql' : target; + return configure(prismaProvider, deps); +} + +// Only run if executed directly +const isMain = process.argv[1].endsWith('configure-db.ts') || process.argv[1].endsWith('configure-db.js'); +if (isMain && process.env.NODE_ENV !== 'test') { + run(process.argv.slice(2)); +} diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index 8529eed..651770f 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -79,8 +79,9 @@ export async function GET(req: Request) { return NextResponse.redirect(new URL('/', req.url)) - } catch (err: any) { - logger.error({ event: 'SSO_CALLBACK_ERROR', error: err.message }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + logger.error({ event: 'SSO_CALLBACK_ERROR', error: message }) return NextResponse.redirect(new URL('/login?error=sso_callback_failed', req.url)) } } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index f6d90e9..00b66b7 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -6,7 +6,7 @@ import { logger } from '@/lib/logger' export async function POST(req: Request) { const { username, password } = await req.json() - let isMock = String(process.env.MOCK_LDAP).replace(/["']/g, '').trim().toLowerCase() === 'true' || !process.env.LDAP_URL + const isMock = String(process.env.MOCK_LDAP).replace(/["']/g, '').trim().toLowerCase() === 'true' || !process.env.LDAP_URL let success = false; if (isMock) { @@ -15,7 +15,7 @@ export async function POST(req: Request) { } } else { try { - const options: any = { + const options: Record = { ldapOpts: { url: process.env.LDAP_URL || '' }, userPassword: password } @@ -40,7 +40,8 @@ export async function POST(req: Request) { options.usernameAttribute = process.env.LDAP_USERNAME_ATTRIBUTE || 'cn' } - const user = await authenticate(options) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user = await authenticate(options as any) if (user) success = true } catch (err) { logger.error({ event: 'LDAP_AUTH_ERROR', username, error: err }) diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts index 0233649..d7df6d6 100644 --- a/src/app/api/auth/sso/route.ts +++ b/src/app/api/auth/sso/route.ts @@ -25,8 +25,9 @@ export async function GET(req: Request) { }) return NextResponse.redirect(url.toString()) - } catch (err: any) { - logger.error({ event: 'SSO_AUTH_ERROR', error: err.message }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + logger.error({ event: 'SSO_AUTH_ERROR', error: message }) return NextResponse.redirect(new URL('/login?error=sso_failed_config', req.url)) } } diff --git a/src/app/api/collections/route.ts b/src/app/api/collections/route.ts index 7d14627..dcc97c7 100644 --- a/src/app/api/collections/route.ts +++ b/src/app/api/collections/route.ts @@ -3,7 +3,7 @@ import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' import { logger } from '@/lib/logger' -export async function GET(req: Request) { +export async function GET() { const session = await getSession() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -54,7 +54,8 @@ export async function POST(req: Request) { }) return NextResponse.json({ collection: newCollection }) - } catch (err: any) { - return NextResponse.json({ error: 'Failed to save collection', details: err.message }, { status: 500 }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + return NextResponse.json({ error: 'Failed to save collection', details: message }, { status: 500 }) } } diff --git a/src/app/api/requests/[id]/approve/route.ts b/src/app/api/requests/[id]/approve/route.ts index d0eee6b..e9c47c9 100644 --- a/src/app/api/requests/[id]/approve/route.ts +++ b/src/app/api/requests/[id]/approve/route.ts @@ -10,32 +10,41 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const request = await prisma.httpRequest.findUnique({ - where: { id: p.id } - }) - if (!request) { - return NextResponse.json({ error: 'Request not found' }, { status: 404 }) - } + try { + const request = await prisma.httpRequest.findUnique({ + where: { id: p.id } + }) + if (!request) { + return NextResponse.json({ error: 'Request not found' }, { status: 404 }) + } - if (request.requesterId === session.id) { - return NextResponse.json({ error: 'You cannot approve your own request' }, { status: 403 }) - } + if (request.requesterId === session.id) { + return NextResponse.json({ error: 'You cannot approve your own request' }, { status: 403 }) + } - await prisma.httpRequest.update({ - where: { id: p.id }, - data: { - status: 'APPROVED', - approverId: session.id, - approvedAt: new Date() + if (request.status !== 'PENDING') { + return NextResponse.json({ error: 'Only pending requests can be approved' }, { status: 400 }) } - }) - logger.info({ - event: 'REQUEST_APPROVED', - userId: session.id, - username: session.username, - metadata: { requestId: p.id } - }) + await prisma.httpRequest.update({ + where: { id: p.id }, + data: { + status: 'APPROVED', + approverId: session.id, + approvedAt: new Date() + } + }) + + logger.info({ + event: 'REQUEST_APPROVED', + userId: session.id, + username: session.username, + metadata: { requestId: p.id } + }) - return NextResponse.json({ request }) + return NextResponse.json({ request }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + return NextResponse.json({ error: 'Internal server error', details: message }, { status: 500 }) + } } diff --git a/src/app/api/requests/[id]/execute/route.ts b/src/app/api/requests/[id]/execute/route.ts index d16fdea..d434c6e 100644 --- a/src/app/api/requests/[id]/execute/route.ts +++ b/src/app/api/requests/[id]/execute/route.ts @@ -8,63 +8,64 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str const session = await getSession() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const request = await prisma.httpRequest.findUnique({ where: { id: p.id } }) - if (!request) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - if (request.status !== 'APPROVED') return NextResponse.json({ error: 'Request not approved' }, { status: 400 }) - if (request.requesterId !== session.id) return NextResponse.json({ error: 'Only requester can execute' }, { status: 403 }) - - try { - let parsedHeaders = request.headers ? JSON.parse(request.headers) : {} - const fetchOptions: RequestInit = { - method: request.method, - headers: parsedHeaders, - } - if (request.body && ['POST', 'PUT', 'PATCH'].includes(request.method.toUpperCase())) { - fetchOptions.body = request.body - } - - const startTime = performance.now() - const response = await fetch(request.url, fetchOptions) - const respText = await response.text() - const executionTimeMs = Math.round(performance.now() - startTime) - - // Assume we store part of response body or headers safely, truncated if needed - const responseData = JSON.stringify({ - status: response.status, - statusText: response.statusText, - body: respText.slice(0, 50000) // limit size to 50KB - }) - - const updatedRequest = await prisma.httpRequest.update({ - where: { id: p.id }, - data: { - status: 'EXECUTED', - response: responseData, - executedAt: new Date() + const request = await prisma.httpRequest.findUnique({ where: { id: p.id } }) + if (!request) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + if (request.status !== 'APPROVED') return NextResponse.json({ error: 'Request not approved' }, { status: 400 }) + if (request.requesterId !== session.id) return NextResponse.json({ error: 'Only requester can execute' }, { status: 403 }) + + try { + const parsedHeaders = request.headers ? JSON.parse(request.headers) : {} + const fetchOptions: RequestInit = { + method: request.method, + headers: parsedHeaders, } - }) - - logger.info({ - event: 'REQUEST_EXECUTED', - userId: session.id, - username: session.username, - metadata: { - requestId: p.id, - httpStatus: response.status, - targetUrl: request.url, - executionTimeMs + if (request.body && ['POST', 'PUT', 'PATCH'].includes(request.method.toUpperCase())) { + fetchOptions.body = request.body } - }) - - return NextResponse.json({ request: updatedRequest }) - } catch (err: any) { - logger.error({ - event: 'REQUEST_EXECUTION_FAILED', - userId: session.id, - username: session.username, - metadata: { requestId: p.id, targetUrl: request.url }, - error: err - }) - return NextResponse.json({ error: 'Execution failed', details: err.message }, { status: 500 }) + + const startTime = performance.now() + const response = await fetch(request.url, fetchOptions) + const respText = await response.text() + const executionTimeMs = Math.round(performance.now() - startTime) + + // Assume we store part of response body or headers safely, truncated if needed + const responseData = JSON.stringify({ + status: response.status, + statusText: response.statusText, + body: respText.slice(0, 50000) // limit size to 50KB + }) + + const updatedRequest = await prisma.httpRequest.update({ + where: { id: p.id }, + data: { + status: 'EXECUTED', + response: responseData, + executedAt: new Date() + } + }) + + logger.info({ + event: 'REQUEST_EXECUTED', + userId: session.id, + username: session.username, + metadata: { + requestId: p.id, + httpStatus: response.status, + targetUrl: request.url, + executionTimeMs + } + }) + + return NextResponse.json({ request: updatedRequest }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Execution failed' + logger.error({ + event: 'REQUEST_EXECUTION_FAILED', + userId: session.id, + username: session.username, + metadata: { requestId: p.id, targetUrl: request.url }, + error: err + }) + return NextResponse.json({ error: 'Execution failed', details: message }, { status: 500 }) + } } -} diff --git a/src/app/api/requests/[id]/reject/route.ts b/src/app/api/requests/[id]/reject/route.ts index ce2f373..52db22b 100644 --- a/src/app/api/requests/[id]/reject/route.ts +++ b/src/app/api/requests/[id]/reject/route.ts @@ -21,6 +21,10 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str return NextResponse.json({ error: 'You cannot reject your own request' }, { status: 403 }) } + if (request.status !== 'PENDING') { + return NextResponse.json({ error: 'Only pending requests can be rejected' }, { status: 400 }) + } + await prisma.httpRequest.update({ where: { id: p.id }, data: { diff --git a/src/app/api/requests/[id]/route.ts b/src/app/api/requests/[id]/route.ts index 21b7fd1..d36c98b 100644 --- a/src/app/api/requests/[id]/route.ts +++ b/src/app/api/requests/[id]/route.ts @@ -73,7 +73,8 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st }) return NextResponse.json({ request: updatedRequest }) - } catch (err: any) { - return NextResponse.json({ error: 'Update failed', details: err.message }, { status: 500 }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Update failed' + return NextResponse.json({ error: 'Update failed', details: message }, { status: 500 }) } } diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index 63d794b..0335f00 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { logger } from '@/lib/logger' +import { Prisma } from '@prisma/client' export async function GET(req: Request) { const session = await getSession() @@ -9,10 +10,9 @@ export async function GET(req: Request) { const { searchParams } = new URL(req.url) const role = session.role - let requests = [] const q = searchParams.get('q') || '' - const whereClause: any = {} + const whereClause: Prisma.HttpRequestWhereInput = {} if (role !== 'APPROVER') { whereClause.requesterId = session.id } @@ -25,7 +25,7 @@ export async function GET(req: Request) { ] } - requests = await prisma.httpRequest.findMany({ + const requests = await prisma.httpRequest.findMany({ where: whereClause, orderBy: { createdAt: 'desc' }, take: 15, diff --git a/src/app/collections/page.tsx b/src/app/collections/page.tsx index a1d1727..e8942dd 100644 --- a/src/app/collections/page.tsx +++ b/src/app/collections/page.tsx @@ -1,19 +1,25 @@ 'use client' import useSWR from 'swr' import { useRouter } from 'next/navigation' +import Image from 'next/image' import Link from 'next/link' import { useEffect, useState } from 'react' +import { UserSession, RequestCollectionData, HttpRequestData } from '@/lib/types' +import { getMethodColor } from '@/lib/utils' +import Inspector from '@/components/Inspector' const fetcher = (url: string) => fetch(url).then(res => res.json()) export default function Collections() { const router = useRouter() const [searchQuery, setSearchQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') const [toast, setToast] = useState<{ msg: string, type: 'error' | 'success' } | null>(null) - const [inspectCollection, setInspectCollection] = useState(null) - const [inspectTab, setInspectTab] = useState('Params') - // Create Mode States + // Inspection Logic + const [selectedTemplate, setSelectedTemplate] = useState(null) + + // Create/Edit Mode States const [showCreateModal, setShowCreateModal] = useState(false) const [createTab, setCreateTab] = useState('Params') const [newName, setNewName] = useState('') @@ -28,69 +34,23 @@ export default function Collections() { const [newBody, setNewBody] = useState('') const [newIsGlobal, setNewIsGlobal] = useState(false) const [editCollectionId, setEditCollectionId] = useState(null) - const [deleteId, setDeleteId] = useState(null) - const [isDeleting, setIsDeleting] = useState(false) const [isSaving, setIsSaving] = useState(false) - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setInspectCollection(null) - } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, []) - - const renderKVTable = (record: Record) => { - const entries = Object.entries(record) - if (entries.length === 0) return
No values provided.
- return ( -
-
-
KEY
-
VALUE
-
- {entries.map(([k, v]) => ( -
-
{k}
-
{v as string}
-
- ))} -
- ) - } - - const handleHeaderRowChange = (index: number, field: 'key' | 'value', val: string) => { - const newArr = [...newHeadersArr] - newArr[index][field] = val - setNewHeadersArr(newArr) - if (index === newHeadersArr.length - 1 && val !== '') { - setNewHeadersArr([...newArr, { key: '', value: '' }]) - } - } - - const handleRemoveHeaderRow = (index: number) => { - setNewHeadersArr(newHeadersArr.filter((_, i) => i !== index)) - } - - const handleParamRowChange = (index: number, field: 'key' | 'value', val: string) => { - const newArr = [...newParamsArr] - newArr[index][field] = val - setNewParamsArr(newArr) - if (index === newParamsArr.length - 1 && val !== '') { - setNewParamsArr([...newArr, { key: '', value: '' }]) - } - } - - const handleRemoveParamRow = (index: number) => { - setNewParamsArr(newParamsArr.filter((_, i) => i !== index)) - } - const showToast = (msg: string, type: 'error' | 'success' = 'error') => { setToast({ msg, type }) setTimeout(() => setToast(null), 3000) } - const { data: auth, error: authError } = useSWR('/api/auth/me', fetcher) - const { data: cols, mutate } = useSWR(auth?.user ? '/api/collections' : null, fetcher) + + useEffect(() => { + const t = setTimeout(() => setDebouncedQuery(searchQuery), 300) + return () => clearTimeout(t) + }, [searchQuery]) + + const { data: auth, error: authError } = useSWR<{ user: UserSession } | null>('/api/auth/me', fetcher) + const { data: cols, mutate } = useSWR<{ collections: RequestCollectionData[] } | null>( + auth?.user ? `/api/collections?q=${encodeURIComponent(debouncedQuery)}` : null, + fetcher + ) useEffect(() => { if (authError || (auth && !auth.user)) { @@ -98,15 +58,26 @@ export default function Collections() { } }, [auth, authError, router]) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setSelectedTemplate(null) + setShowCreateModal(false) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + if (authError || (auth && !auth.user)) return null - if (!auth || !cols) return
Loading...
+ if (!auth || !cols) return
Synchronizing Registry...
const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }) router.push('/login') } - const handleDraft = (c: any) => { + const handleDraft = (c: RequestCollectionData) => { sessionStorage.setItem('clone_request', JSON.stringify({ method: c.method, url: c.url, @@ -116,35 +87,31 @@ export default function Collections() { router.push('/create') } - const handleDelete = async () => { - if (!deleteId) return - setIsDeleting(true) + const handleDelete = async (e: React.MouseEvent, id: string) => { + e.stopPropagation() + if (!confirm('Are you sure you want to delete this blueprint?')) return try { - const res = await fetch(`/api/collections/${deleteId}`, { method: 'DELETE' }) + const res = await fetch(`/api/collections/${id}`, { method: 'DELETE' }) if (res.ok) { mutate() showToast('Template deleted successfully', 'success') - setDeleteId(null) } else { const err = await res.json() showToast('Failed: ' + err.error, 'error') } - } catch (err) { + } catch { showToast('Network error') - } finally { - setIsDeleting(false) } } - const handleEditStart = (e: React.MouseEvent, c: any) => { + const handleEditStart = (e: React.MouseEvent, c: RequestCollectionData) => { e.stopPropagation() setEditCollectionId(c.id) setNewName(c.name) setNewMethod(c.method) setNewBody(c.body || '') setNewIsGlobal(c.isGlobal) - - // Parse URL and Params + let targetUrl = c.url || '' try { const u = new URL(targetUrl) @@ -152,12 +119,11 @@ export default function Collections() { u.searchParams.forEach((v, k) => pArr.push({ key: k, value: v })) setNewParamsArr(pArr.length > 0 ? [...pArr, { key: '', value: '' }] : [{ key: '', value: '' }]) targetUrl = u.origin + u.pathname - } catch(e) { + } catch { setNewParamsArr([{ key: '', value: '' }]) } setNewUrl(targetUrl) - // Parse Headers and Auth if (c.headers) { try { const hObj = JSON.parse(c.headers) @@ -178,7 +144,7 @@ export default function Collections() { setNewBasicUser(user) setNewBasicPass(pass.join(':')) authFound = true - } catch(e) {} + } catch { } } else { hArr.push({ key: k, value: val }) } @@ -188,7 +154,7 @@ export default function Collections() { }) if (!authFound) setNewAuthType('None') setNewHeadersArr(hArr.length > 0 ? [...hArr, { key: '', value: '' }] : [{ key: '', value: '' }]) - } catch(e) { + } catch { setNewHeadersArr([{ key: '', value: '' }]) } } else { @@ -207,7 +173,6 @@ export default function Collections() { } setIsSaving(true) - // Merge Params into URL let finalUrl = newUrl const validParams = newParamsArr.filter(p => p.key.trim() !== '') if (validParams.length > 0) { @@ -215,7 +180,7 @@ export default function Collections() { const urlObj = new URL(newUrl) validParams.forEach(p => urlObj.searchParams.append(p.key.trim(), p.value.trim())) finalUrl = urlObj.toString() - } catch (err) { + } catch { const qs = validParams.map(p => `${encodeURIComponent(p.key.trim())}=${encodeURIComponent(p.value.trim())}`).join('&') finalUrl = finalUrl.includes('?') ? `${finalUrl}&${qs}` : `${finalUrl}?${qs}` } @@ -226,7 +191,6 @@ export default function Collections() { if (h.key.trim()) parsedHeaders[h.key.trim()] = h.value.trim() }) - // Handle Auth injection if (newAuthType === 'Bearer Token' && newBearerToken.trim() !== '') { parsedHeaders['Authorization'] = `Bearer ${newBearerToken.trim()}` } else if (newAuthType === 'Basic Auth' && (newBasicUser || newBasicPass)) { @@ -251,82 +215,81 @@ export default function Collections() { if (res.ok) { mutate() setShowCreateModal(false) - showToast(editCollectionId ? 'Template updated successfully' : 'Template created successfully', 'success') - // Reset form - setEditCollectionId(null) - setNewName('') - setNewMethod('GET') - setNewUrl('') - setNewParamsArr([{ key: '', value: '' }]) - setNewHeadersArr([{ key: '', value: '' }]) - setNewAuthType('None') - setNewBearerToken('') - setNewBasicUser('') - setNewBasicPass('') - setNewBody('') - setNewIsGlobal(false) + showToast(editCollectionId ? 'Blueprint updated' : 'Blueprint construction complete', 'success') } else { const err = await res.json() - showToast('Failed to create: ' + err.error, 'error') + showToast('Construction failed: ' + err.error, 'error') } - } catch (err) { - showToast('Network error occurred', 'error') } finally { setIsSaving(false) } } - const handleToggleGlobal = async (c: any) => { + const handleToggleGlobal = async (e: React.MouseEvent, c: RequestCollectionData) => { + e.stopPropagation() const res = await fetch(`/api/collections/${c.id}`, { method: 'PATCH', body: JSON.stringify({ isGlobal: !c.isGlobal }) }) if (res.ok) { mutate() - showToast(`Template is now ${!c.isGlobal ? 'Global' : 'Private'}`, 'success') - } else { - const err = await res.json() - showToast('Failed to toggle visibility: ' + err.error, 'error') + showToast(`Blueprint is now ${!c.isGlobal ? 'Global' : 'Private'}`, 'success') } } return ( -
+
- {/* Navigation Header */} -
-
- - Heimdall Logo + {/* Premium Header */} +
+
+ +
+
+ Heimdall Logo +
-

HEIMDALL

- Project +

HEIMDALL

+ Security Audit Platform
-
-
- Logged in as {auth.user.username} ({auth.user.role}) - +
+
+ Operator Access + {auth.user.username} [{auth.user.role}] +
+
-
-
-

Request Templates

-

Standardized blueprints configured for immediate rapid access.

+ {/* Title & Actions */} +
+
+

Registry & Blueprints

+

Standardized templates for immediate payload dispatch.

-
-
+
+
+
+ +
setSearchQuery(e.target.value)} - className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2 text-sm text-white focus:ring-2 focus:ring-[#f26b3a] outline-none" + onChange={(e) => setSearchQuery(e.target.value)} + className="w-full bg-[#2a2a2a] border border-[#333] pl-12 pr-4 py-3 rounded-2xl text-sm text-zinc-300 outline-none focus:border-[#f26b3a] focus:ring-4 focus:ring-[#f26b3a]/10 transition shadow-inner placeholder:text-zinc-600 placeholder:italic placeholder:font-medium" />
-
+ {/* Premium Table Container */} +
- - - - - - - + + + + + + + - + {cols.collections.length === 0 && ( - - - + )} - {cols.collections.filter((c: any) => c.name.toLowerCase().includes(searchQuery.toLowerCase()) || c.url.toLowerCase().includes(searchQuery.toLowerCase())).map((c: any) => ( + {cols.collections.map((c: RequestCollectionData) => ( { setInspectCollection(c); setInspectTab('Params') }} - className="border-b border-[#333] last:border-b-0 hover:bg-[#252525] transition cursor-pointer group" + onClick={() => setSelectedTemplate(c)} + className="group hover:bg-[#2a2a2a]/40 transition-colors cursor-pointer" > - - - - - -
NAME & CREATORMETHODURLVISIBILITYCREATEDACTIONS
Template NameMethodTarget EndpointVisibilityCreatorActions
- No collections found. Head over to the Dashboard to save a Payload Template! -
Blueprint registry is currently empty.
-
{c.name}
-
By {c.creator?.username || 'Unknown'}
+
+
{c.name}
+
Registry ID: {c.id.split('-')[0]}
- {c.method} + + {c.method} - {c.url} + +
{c.url}
+ {c.isGlobal ? ( - Global + Global ) : ( - Private + Private )} - {new Date(c.createdAt).toLocaleDateString()} + + {c.creator?.username || 'Core System'} e.stopPropagation()}> -
+
e.stopPropagation()}> +
@@ -415,31 +371,24 @@ export default function Collections() {
)} @@ -450,399 +399,192 @@ export default function Collections() {
-
+ {/* Blueprint Construction Modal */} {showCreateModal && ( -
{ setShowCreateModal(false); setEditCollectionId(null); }}> +
{ setShowCreateModal(false); setEditCollectionId(null); }}>
e.stopPropagation()} > -
-
+
+
-

{editCollectionId ? 'Edit Request Template' : 'Create Request Template'}

-

{editCollectionId ? 'Update Existing Blueprint' : 'Blueprint Construction Mode'}

+

{editCollectionId ? 'Blueprint Refinement' : 'Blueprint Construction'}

+

Serializing audit standardized configurations

- +
-
-
- +
+
+ setNewName(e.target.value)} - placeholder="e.g. User Profile Sync Payload" - className="w-full bg-[#1e1e1e] border border-[#333] rounded px-3 py-2 text-white outline-none focus:border-[#f26b3a] transition" + placeholder="e.g. AUTH_V2_PROFILE_VALIDATOR" + className="w-full bg-[#2a2a2a] border border-[#333] rounded-2xl px-5 py-3 text-sm text-white focus:border-[#f26b3a] focus:ring-4 focus:ring-[#f26b3a]/10 transition outline-none" />
-
- - -
-
- -
- -
- - - -
+
+ +
+ {newIsGlobal ? 'Global' : 'Private'} +
-
- +
+ + +
+
+ setNewUrl(e.target.value)} - placeholder="Enter request URL" - className="w-full bg-[#1e1e1e] border border-[#333] rounded px-3 py-2 text-white font-mono text-sm outline-none focus:border-[#f26b3a] transition" + placeholder="https://infrastructure.api/v1/resource" + className="w-full bg-[#2a2a2a] border border-[#333] rounded-2xl px-5 py-3.5 text-sm font-mono text-zinc-300 focus:border-[#f26b3a] transition outline-none" />
-
+
{['Params', 'Auth', 'Headers', 'Body'].map(tab => ( ))}
-
+
{createTab === 'Params' && ( -
-
-
Key
-
Value
-
-
+
{newParamsArr.map((h, i) => ( -
-
- handleParamRowChange(i, 'key', e.target.value)} - placeholder="Key" - className="w-full bg-transparent px-3 py-1.5 outline-none text-zinc-300 font-mono text-xs focus:bg-[#252525]" - /> -
-
- handleParamRowChange(i, 'value', e.target.value)} - placeholder="Value" - className="w-full bg-transparent px-3 py-1.5 outline-none text-zinc-300 font-mono text-xs focus:bg-[#252525]" - /> -
-
- -
+
+ { const a = [...newParamsArr]; a[i].key = e.target.value; setNewParamsArr(a); if (i === a.length - 1 && e.target.value) setNewParamsArr([...a, { key: '', value: '' }]); }} placeholder="Key" className="flex-1 bg-zinc-900/50 border border-zinc-800 rounded-xl px-4 py-2 text-xs font-mono text-zinc-300 focus:border-[#f26b3a] outline-none transition" /> + { const a = [...newParamsArr]; a[i].value = e.target.value; setNewParamsArr(a); }} placeholder="Value" className="flex-[2] bg-zinc-900/50 border border-zinc-800 rounded-xl px-4 py-2 text-xs font-mono text-zinc-300 focus:border-[#f26b3a] outline-none transition" /> +
))}
)} - {createTab === 'Auth' && ( -
-
- Auth Type - setNewAuthType(e.target.value)} className="bg-[#1c1c1c] border border-zinc-800 rounded-xl px-4 py-2 text-xs font-bold text-zinc-400 outline-none focus:border-[#f26b3a] transition cursor-pointer"> + + +
-
- {newAuthType === 'None' &&

This request does not use any authorization.

} - {newAuthType === 'Bearer Token' && ( -
- - setNewBearerToken(e.target.value)} - placeholder="Enter Bearer Token" - className="bg-[#2a2a2a] px-3 py-2 border border-[#333] rounded text-zinc-300 font-mono text-xs outline-none focus:border-[#f26b3a]" - /> + {newAuthType === 'Bearer Token' && ( +
+ + setNewBearerToken(e.target.value)} className="bg-[#1c1c1c] border border-zinc-800 rounded-xl px-4 py-2 text-xs font-mono text-zinc-300 outline-none focus:border-[#f26b3a] transition" /> +
+ )} + {newAuthType === 'Basic Auth' && ( +
+
+ + setNewBasicUser(e.target.value)} className="bg-[#1c1c1c] border border-zinc-800 rounded-xl px-4 py-2 text-xs font-mono text-zinc-300 outline-none focus:border-[#f26b3a] transition" />
- )} - {newAuthType === 'Basic Auth' && ( -
-
- - setNewBasicUser(e.target.value)} - className="bg-[#2a2a2a] px-3 py-2 border border-[#333] rounded text-zinc-300 font-mono text-xs outline-none focus:border-[#f26b3a]" - /> -
-
- - setNewBasicPass(e.target.value)} - className="bg-[#2a2a2a] px-3 py-2 border border-[#333] rounded text-zinc-300 font-mono text-xs outline-none focus:border-[#f26b3a]" - /> -
+
+ + setNewBasicPass(e.target.value)} className="bg-[#1c1c1c] border border-zinc-800 rounded-xl px-4 py-2 text-xs font-mono text-zinc-300 outline-none focus:border-[#f26b3a] transition" />
- )} -
+
+ )}
)} - {createTab === 'Headers' && ( -
-
-
Key
-
Value
-
-
+
{newHeadersArr.map((h, i) => ( -
-
- handleHeaderRowChange(i, 'key', e.target.value)} - placeholder="Key" - className="w-full bg-transparent px-3 py-1.5 outline-none text-zinc-300 font-mono text-xs focus:bg-[#252525]" - /> -
-
- handleHeaderRowChange(i, 'value', e.target.value)} - placeholder="Value" - className="w-full bg-transparent px-3 py-1.5 outline-none text-zinc-300 font-mono text-xs focus:bg-[#252525]" - /> -
-
- -
+
+ { const a = [...newHeadersArr]; a[i].key = e.target.value; setNewHeadersArr(a); if (i === a.length - 1 && e.target.value) setNewHeadersArr([...a, { key: '', value: '' }]); }} placeholder="Header Key" className="flex-1 bg-zinc-900/50 border border-zinc-800 rounded-xl px-4 py-2 text-xs font-mono text-zinc-300 focus:border-[#f26b3a] outline-none transition" /> + { const a = [...newHeadersArr]; a[i].value = e.target.value; setNewHeadersArr(a); }} placeholder="Value" className="flex-[2] bg-zinc-900/50 border border-zinc-800 rounded-xl px-4 py-2 text-xs font-mono text-zinc-300 focus:border-[#f26b3a] outline-none transition" /> +
))}
)} - {createTab === 'Body' && ( -