feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
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',
|
2026-02-23 01:02:41 +00:00
|
|
|
passwordHash: '$2b$10$test-hash-placeholder',
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
role: overrides.role ?? 'USER',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat: granular RBAC with resource/operation bindings, users, groups
- Replace admin role with granular roles: view, create, delete, edit, run
- Two binding types: resource bindings (role+resource+optional name) and
operation bindings (role:run + action like backup, logs, impersonate)
- Name-scoped resource bindings for per-instance access control
- Remove role from project members (all permissions via RBAC)
- Add users, groups, RBAC CRUD endpoints and CLI commands
- describe user/group shows all RBAC access (direct + inherited)
- create rbac supports --subject, --binding, --operation flags
- Backup/restore handles users, groups, RBAC definitions
- mcplocal project-based MCP endpoint discovery
- Full test coverage for all new functionality
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:05:19 +00:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
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);
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
expect(server.env).toEqual([]);
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('enforces unique name', async () => {
|
|
|
|
|
await createServer({ name: 'slack' });
|
|
|
|
|
await expect(createServer({ name: 'slack' })).rejects.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
it('stores env as JSON', async () => {
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
const server = await prisma.mcpServer.create({
|
|
|
|
|
data: {
|
|
|
|
|
name: 'with-env',
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
env: [
|
|
|
|
|
{ name: 'API_KEY', value: 'test-key' },
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
});
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
const env = server.env as Array<{ name: string }>;
|
|
|
|
|
expect(env).toHaveLength(1);
|
|
|
|
|
expect(env[0].name).toBe('API_KEY');
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('supports SSE transport', async () => {
|
|
|
|
|
const server = await createServer({ transport: 'SSE' });
|
|
|
|
|
expect(server.transport).toBe('SSE');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
// ── Secret model ──
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
describe('Secret', () => {
|
|
|
|
|
it('creates a secret with defaults', async () => {
|
|
|
|
|
const secret = await prisma.secret.create({
|
|
|
|
|
data: { name: 'my-secret' },
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
});
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
expect(secret.name).toBe('my-secret');
|
|
|
|
|
expect(secret.data).toEqual({});
|
|
|
|
|
expect(secret.version).toBe(1);
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
});
|
|
|
|
|
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
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<string, string>;
|
|
|
|
|
expect(data['API_KEY']).toBe('test-key');
|
|
|
|
|
expect(data['API_SECRET']).toBe('test-secret');
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
});
|
|
|
|
|
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
it('enforces unique name', async () => {
|
|
|
|
|
await prisma.secret.create({ data: { name: 'dup-secret' } });
|
|
|
|
|
await expect(prisma.secret.create({ data: { name: 'dup-secret' } })).rejects.toThrow();
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
});
|
|
|
|
|
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
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<string, string>;
|
|
|
|
|
expect(data['KEY']).toBe('new');
|
|
|
|
|
expect(data['EXTRA']).toBe('added');
|
feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:10:40 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── 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);
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: granular RBAC with resource/operation bindings, users, groups
- Replace admin role with granular roles: view, create, delete, edit, run
- Two binding types: resource bindings (role+resource+optional name) and
operation bindings (role:run + action like backup, logs, impersonate)
- Name-scoped resource bindings for per-instance access control
- Remove role from project members (all permissions via RBAC)
- Add users, groups, RBAC CRUD endpoints and CLI commands
- describe user/group shows all RBAC access (direct + inherited)
- create rbac supports --subject, --binding, --operation flags
- Backup/restore handles users, groups, RBAC definitions
- mcplocal project-based MCP endpoint discovery
- Full test coverage for all new functionality
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:05:19 +00:00
|
|
|
|
|
|
|
|
// ── 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();
|
|
|
|
|
});
|
|
|
|
|
});
|