import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import type { PrismaClient } from '@prisma/client'; import { setupTestDb, cleanupTestDb, clearAllTables, getTestClient } from './helpers.js'; let prisma: PrismaClient; beforeAll(async () => { prisma = await setupTestDb(); }, 30_000); afterAll(async () => { await cleanupTestDb(); }); beforeEach(async () => { await clearAllTables(prisma); }); // ── Helper factories ── async function createUser(overrides: { email?: string; name?: string; role?: 'USER' | 'ADMIN' } = {}) { return prisma.user.create({ data: { email: overrides.email ?? `test-${Date.now()}@example.com`, name: overrides.name ?? 'Test User', passwordHash: '$2b$10$test-hash-placeholder', role: overrides.role ?? 'USER', }, }); } async function createGroup(overrides: { name?: string; description?: string } = {}) { return prisma.group.create({ data: { name: overrides.name ?? `group-${Date.now()}`, description: overrides.description ?? 'Test group', }, }); } async function createProject(overrides: { name?: string; ownerId?: string } = {}) { let ownerId = overrides.ownerId; if (!ownerId) { const user = await createUser(); ownerId = user.id; } return prisma.project.create({ data: { name: overrides.name ?? `project-${Date.now()}`, ownerId, }, }); } async function createServer(overrides: { name?: string; transport?: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP' } = {}) { return prisma.mcpServer.create({ data: { name: overrides.name ?? `server-${Date.now()}`, description: 'Test server', packageName: '@test/mcp-server', transport: overrides.transport ?? 'STDIO', }, }); } // ── User model ── describe('User', () => { it('creates a user with defaults', async () => { const user = await createUser(); expect(user.id).toBeDefined(); expect(user.role).toBe('USER'); expect(user.version).toBe(1); expect(user.createdAt).toBeInstanceOf(Date); expect(user.updatedAt).toBeInstanceOf(Date); }); it('enforces unique email', async () => { await createUser({ email: 'dup@test.com' }); await expect(createUser({ email: 'dup@test.com' })).rejects.toThrow(); }); it('allows ADMIN role', async () => { const admin = await createUser({ role: 'ADMIN' }); expect(admin.role).toBe('ADMIN'); }); it('updates updatedAt on change', async () => { const user = await createUser(); const original = user.updatedAt; // Small delay to ensure different timestamp await new Promise((r) => setTimeout(r, 50)); const updated = await prisma.user.update({ where: { id: user.id }, data: { name: 'Updated' }, }); expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(original.getTime()); }); }); // ── Session model ── describe('Session', () => { it('creates a session linked to user', async () => { const user = await createUser(); const session = await prisma.session.create({ data: { token: 'test-token-123', userId: user.id, expiresAt: new Date(Date.now() + 86400_000), }, }); expect(session.token).toBe('test-token-123'); expect(session.userId).toBe(user.id); }); it('enforces unique token', async () => { const user = await createUser(); const data = { token: 'unique-token', userId: user.id, expiresAt: new Date(Date.now() + 86400_000), }; await prisma.session.create({ data }); await expect(prisma.session.create({ data })).rejects.toThrow(); }); it('cascades delete when user is deleted', async () => { const user = await createUser(); await prisma.session.create({ data: { token: 'cascade-token', userId: user.id, expiresAt: new Date(Date.now() + 86400_000), }, }); await prisma.user.delete({ where: { id: user.id } }); const sessions = await prisma.session.findMany({ where: { userId: user.id } }); expect(sessions).toHaveLength(0); }); }); // ── McpServer model ── describe('McpServer', () => { it('creates a server with defaults', async () => { const server = await createServer(); expect(server.transport).toBe('STDIO'); expect(server.version).toBe(1); expect(server.env).toEqual([]); }); it('enforces unique name', async () => { await createServer({ name: 'slack' }); await expect(createServer({ name: 'slack' })).rejects.toThrow(); }); it('stores env as JSON', async () => { const server = await prisma.mcpServer.create({ data: { name: 'with-env', env: [ { name: 'API_KEY', value: 'test-key' }, ], }, }); const env = server.env as Array<{ name: string }>; expect(env).toHaveLength(1); expect(env[0].name).toBe('API_KEY'); }); it('supports SSE transport', async () => { const server = await createServer({ transport: 'SSE' }); expect(server.transport).toBe('SSE'); }); }); // ── Secret model ── describe('Secret', () => { it('creates a secret with defaults', async () => { const secret = await prisma.secret.create({ data: { name: 'my-secret' }, }); expect(secret.name).toBe('my-secret'); expect(secret.data).toEqual({}); expect(secret.version).toBe(1); }); it('stores key-value data as JSON', async () => { const secret = await prisma.secret.create({ data: { name: 'api-keys', data: { API_KEY: 'test-key', API_SECRET: 'test-secret' }, }, }); const data = secret.data as Record; expect(data['API_KEY']).toBe('test-key'); expect(data['API_SECRET']).toBe('test-secret'); }); it('enforces unique name', async () => { await prisma.secret.create({ data: { name: 'dup-secret' } }); await expect(prisma.secret.create({ data: { name: 'dup-secret' } })).rejects.toThrow(); }); it('updates data', async () => { const secret = await prisma.secret.create({ data: { name: 'updatable', data: { KEY: 'old' } }, }); const updated = await prisma.secret.update({ where: { id: secret.id }, data: { data: { KEY: 'new', EXTRA: 'added' } }, }); const data = updated.data as Record; expect(data['KEY']).toBe('new'); expect(data['EXTRA']).toBe('added'); }); }); // ── Project model ── describe('Project', () => { it('creates a project with owner', async () => { const user = await createUser(); const project = await prisma.project.create({ data: { name: 'weekly-reports', ownerId: user.id }, }); expect(project.name).toBe('weekly-reports'); expect(project.ownerId).toBe(user.id); }); it('enforces unique project name', async () => { const user = await createUser(); await prisma.project.create({ data: { name: 'dup', ownerId: user.id } }); await expect( prisma.project.create({ data: { name: 'dup', ownerId: user.id } }), ).rejects.toThrow(); }); it('cascades delete when owner is deleted', async () => { const user = await createUser(); await prisma.project.create({ data: { name: 'orphan', ownerId: user.id } }); await prisma.user.delete({ where: { id: user.id } }); const projects = await prisma.project.findMany({ where: { ownerId: user.id } }); expect(projects).toHaveLength(0); }); }); // ── McpInstance model ── describe('McpInstance', () => { it('creates an instance linked to server', async () => { const server = await createServer(); const instance = await prisma.mcpInstance.create({ data: { serverId: server.id }, }); expect(instance.status).toBe('STOPPED'); expect(instance.serverId).toBe(server.id); }); it('tracks instance status transitions', async () => { const server = await createServer(); const instance = await prisma.mcpInstance.create({ data: { serverId: server.id, status: 'STARTING' }, }); const running = await prisma.mcpInstance.update({ where: { id: instance.id }, data: { status: 'RUNNING', containerId: 'abc123', port: 8080 }, }); expect(running.status).toBe('RUNNING'); expect(running.containerId).toBe('abc123'); expect(running.port).toBe(8080); }); it('cascades delete when server is deleted', async () => { const server = await createServer(); await prisma.mcpInstance.create({ data: { serverId: server.id } }); await prisma.mcpServer.delete({ where: { id: server.id } }); const instances = await prisma.mcpInstance.findMany({ where: { serverId: server.id } }); expect(instances).toHaveLength(0); }); }); // ── AuditLog model ── describe('AuditLog', () => { it('creates an audit log entry', async () => { const user = await createUser(); const log = await prisma.auditLog.create({ data: { userId: user.id, action: 'CREATE', resource: 'McpServer', resourceId: 'server-123', details: { name: 'slack' }, }, }); expect(log.action).toBe('CREATE'); expect(log.resource).toBe('McpServer'); expect(log.createdAt).toBeInstanceOf(Date); }); it('supports querying by action and resource', async () => { const user = await createUser(); await prisma.auditLog.createMany({ data: [ { userId: user.id, action: 'CREATE', resource: 'McpServer' }, { userId: user.id, action: 'UPDATE', resource: 'McpServer' }, { userId: user.id, action: 'CREATE', resource: 'Project' }, ], }); const creates = await prisma.auditLog.findMany({ where: { action: 'CREATE' }, }); expect(creates).toHaveLength(2); const serverLogs = await prisma.auditLog.findMany({ where: { resource: 'McpServer' }, }); expect(serverLogs).toHaveLength(2); }); it('cascades delete when user is deleted', async () => { const user = await createUser(); await prisma.auditLog.create({ data: { userId: user.id, action: 'TEST', resource: 'Test' }, }); await prisma.user.delete({ where: { id: user.id } }); const logs = await prisma.auditLog.findMany({ where: { userId: user.id } }); expect(logs).toHaveLength(0); }); }); // ── User SSO fields ── describe('User SSO fields', () => { it('stores provider and externalId', async () => { const user = await prisma.user.create({ data: { email: 'sso@example.com', passwordHash: 'hash', provider: 'oidc', externalId: 'ext-123', }, }); expect(user.provider).toBe('oidc'); expect(user.externalId).toBe('ext-123'); }); it('defaults provider and externalId to null', async () => { const user = await createUser(); expect(user.provider).toBeNull(); expect(user.externalId).toBeNull(); }); }); // ── Group model ── describe('Group', () => { it('creates a group with defaults', async () => { const group = await createGroup(); expect(group.id).toBeDefined(); expect(group.version).toBe(1); }); it('enforces unique name', async () => { await createGroup({ name: 'devs' }); await expect(createGroup({ name: 'devs' })).rejects.toThrow(); }); it('creates group members', async () => { const group = await createGroup(); const user = await createUser(); const member = await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id }, }); expect(member.groupId).toBe(group.id); expect(member.userId).toBe(user.id); }); it('enforces unique group-user pair', async () => { const group = await createGroup(); const user = await createUser(); await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } }); await expect( prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } }), ).rejects.toThrow(); }); it('cascades delete when group is deleted', async () => { const group = await createGroup(); const user = await createUser(); await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } }); await prisma.group.delete({ where: { id: group.id } }); const members = await prisma.groupMember.findMany({ where: { groupId: group.id } }); expect(members).toHaveLength(0); }); }); // ── RbacDefinition model ── describe('RbacDefinition', () => { it('creates with defaults', async () => { const rbac = await prisma.rbacDefinition.create({ data: { name: 'test-rbac' }, }); expect(rbac.subjects).toEqual([]); expect(rbac.roleBindings).toEqual([]); expect(rbac.version).toBe(1); }); it('enforces unique name', async () => { await prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } }); await expect(prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } })).rejects.toThrow(); }); it('stores subjects as JSON', async () => { const rbac = await prisma.rbacDefinition.create({ data: { name: 'with-subjects', subjects: [{ kind: 'User', name: 'alice@test.com' }, { kind: 'Group', name: 'devs' }], }, }); const subjects = rbac.subjects as Array<{ kind: string; name: string }>; expect(subjects).toHaveLength(2); expect(subjects[0].kind).toBe('User'); }); it('stores roleBindings as JSON', async () => { const rbac = await prisma.rbacDefinition.create({ data: { name: 'with-bindings', roleBindings: [{ role: 'editor', resource: 'servers' }], }, }); const bindings = rbac.roleBindings as Array<{ role: string; resource: string }>; expect(bindings).toHaveLength(1); expect(bindings[0].role).toBe('editor'); }); it('updates subjects and roleBindings', async () => { const rbac = await prisma.rbacDefinition.create({ data: { name: 'updatable-rbac' } }); const updated = await prisma.rbacDefinition.update({ where: { id: rbac.id }, data: { subjects: [{ kind: 'User', name: 'bob@test.com' }], roleBindings: [{ role: 'admin', resource: '*' }], }, }); expect((updated.subjects as unknown[]).length).toBe(1); expect((updated.roleBindings as unknown[]).length).toBe(1); }); }); // ── ProjectServer model ── describe('ProjectServer', () => { it('links project to server', async () => { const project = await createProject(); const server = await createServer(); const ps = await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id }, }); expect(ps.projectId).toBe(project.id); expect(ps.serverId).toBe(server.id); }); it('enforces unique project-server pair', async () => { const project = await createProject(); const server = await createServer(); await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }); await expect( prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }), ).rejects.toThrow(); }); it('cascades delete when project is deleted', async () => { const project = await createProject(); const server = await createServer(); await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }); await prisma.project.delete({ where: { id: project.id } }); const links = await prisma.projectServer.findMany({ where: { projectId: project.id } }); expect(links).toHaveLength(0); }); it('cascades delete when server is deleted', async () => { const project = await createProject(); const server = await createServer(); await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }); await prisma.mcpServer.delete({ where: { id: server.id } }); const links = await prisma.projectServer.findMany({ where: { serverId: server.id } }); expect(links).toHaveLength(0); }); }); // ── ProjectMember model ── describe('ProjectMember', () => { it('links project to user with role', async () => { const user = await createUser(); const project = await createProject({ ownerId: user.id }); const pm = await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id, role: 'admin' }, }); expect(pm.role).toBe('admin'); }); it('defaults role to member', async () => { const user = await createUser(); const project = await createProject({ ownerId: user.id }); const pm = await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id }, }); expect(pm.role).toBe('member'); }); it('enforces unique project-user pair', async () => { const user = await createUser(); const project = await createProject({ ownerId: user.id }); await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } }); await expect( prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } }), ).rejects.toThrow(); }); it('cascades delete when project is deleted', async () => { const user = await createUser(); const project = await createProject({ ownerId: user.id }); await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } }); await prisma.project.delete({ where: { id: project.id } }); const members = await prisma.projectMember.findMany({ where: { projectId: project.id } }); expect(members).toHaveLength(0); }); }); // ── Project new fields ── describe('Project new fields', () => { it('defaults proxyMode to direct', async () => { const project = await createProject(); expect(project.proxyMode).toBe('direct'); }); it('stores proxyMode, llmProvider, llmModel', async () => { const user = await createUser(); const project = await prisma.project.create({ data: { name: 'filtered-project', ownerId: user.id, proxyMode: 'filtered', llmProvider: 'gemini-cli', llmModel: 'gemini-2.0-flash', }, }); expect(project.proxyMode).toBe('filtered'); expect(project.llmProvider).toBe('gemini-cli'); expect(project.llmModel).toBe('gemini-2.0-flash'); }); it('defaults llmProvider and llmModel to null', async () => { const project = await createProject(); expect(project.llmProvider).toBeNull(); expect(project.llmModel).toBeNull(); }); });