feat(#93): implement agent spawn via federation
Implements FED-010: Agent Spawn via Federation feature that enables spawning and managing Claude agents on remote federated Mosaic Stack instances via COMMAND message type. Features: - Federation agent command types (spawn, status, kill) - FederationAgentService for handling agent operations - Integration with orchestrator's agent spawner/lifecycle services - API endpoints for spawning, querying status, and killing agents - Full command routing through federation COMMAND infrastructure - Comprehensive test coverage (12/12 tests passing) Architecture: - Hub → Spoke: Spawn agents on remote instances - Command flow: FederationController → FederationAgentService → CommandService → Remote Orchestrator - Response handling: Remote orchestrator returns agent status/results - Security: Connection validation, signature verification Files created: - apps/api/src/federation/types/federation-agent.types.ts - apps/api/src/federation/federation-agent.service.ts - apps/api/src/federation/federation-agent.service.spec.ts Files modified: - apps/api/src/federation/command.service.ts (agent command routing) - apps/api/src/federation/federation.controller.ts (agent endpoints) - apps/api/src/federation/federation.module.ts (service registration) - apps/orchestrator/src/api/agents/agents.controller.ts (status endpoint) - apps/orchestrator/src/api/agents/agents.module.ts (lifecycle integration) Testing: - 12/12 tests passing for FederationAgentService - All command service tests passing - TypeScript compilation successful - Linting passed Refs #93 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ This directory contains shared guards and decorators for workspace-based permiss
|
||||
## Overview
|
||||
|
||||
The permission system provides:
|
||||
|
||||
- **Workspace isolation** via Row-Level Security (RLS)
|
||||
- **Role-based access control** (RBAC) using workspace member roles
|
||||
- **Declarative permission requirements** using decorators
|
||||
@@ -18,6 +19,7 @@ Located in `../auth/guards/auth.guard.ts`
|
||||
Verifies user authentication and attaches user data to the request.
|
||||
|
||||
**Sets on request:**
|
||||
|
||||
- `request.user` - Authenticated user object
|
||||
- `request.session` - User session data
|
||||
|
||||
@@ -26,23 +28,27 @@ Verifies user authentication and attaches user data to the request.
|
||||
Validates workspace access and sets up RLS context.
|
||||
|
||||
**Responsibilities:**
|
||||
|
||||
1. Extracts workspace ID from request (header, param, or body)
|
||||
2. Verifies user is a member of the workspace
|
||||
3. Sets the current user context for RLS policies
|
||||
4. Attaches workspace context to the request
|
||||
|
||||
**Sets on request:**
|
||||
|
||||
- `request.workspace.id` - Validated workspace ID
|
||||
- `request.user.workspaceId` - Workspace ID (for backward compatibility)
|
||||
|
||||
**Workspace ID Sources (in priority order):**
|
||||
|
||||
1. `X-Workspace-Id` header
|
||||
2. `:workspaceId` URL parameter
|
||||
3. `workspaceId` in request body
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Controller('tasks')
|
||||
@Controller("tasks")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard)
|
||||
export class TasksController {
|
||||
@Get()
|
||||
@@ -57,23 +63,26 @@ export class TasksController {
|
||||
Enforces role-based access control using workspace member roles.
|
||||
|
||||
**Responsibilities:**
|
||||
|
||||
1. Reads required permission from `@RequirePermission()` decorator
|
||||
2. Fetches user's role in the workspace
|
||||
3. Checks if role satisfies the required permission
|
||||
4. Attaches role to request for convenience
|
||||
|
||||
**Sets on request:**
|
||||
|
||||
- `request.user.workspaceRole` - User's role in the workspace
|
||||
|
||||
**Must be used after AuthGuard and WorkspaceGuard.**
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Controller('admin')
|
||||
@Controller("admin")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class AdminController {
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
@Delete('data')
|
||||
@Delete("data")
|
||||
async deleteData() {
|
||||
// Only ADMIN or OWNER can execute
|
||||
}
|
||||
@@ -88,14 +97,15 @@ Specifies the minimum permission level required for a route.
|
||||
|
||||
**Permission Levels:**
|
||||
|
||||
| Permission | Allowed Roles | Use Case |
|
||||
|------------|--------------|----------|
|
||||
| `WORKSPACE_OWNER` | OWNER | Critical operations (delete workspace, transfer ownership) |
|
||||
| `WORKSPACE_ADMIN` | OWNER, ADMIN | Administrative functions (manage members, settings) |
|
||||
| `WORKSPACE_MEMBER` | OWNER, ADMIN, MEMBER | Standard operations (create/edit content) |
|
||||
| `WORKSPACE_ANY` | All roles including GUEST | Read-only or basic access |
|
||||
| Permission | Allowed Roles | Use Case |
|
||||
| ------------------ | ------------------------- | ---------------------------------------------------------- |
|
||||
| `WORKSPACE_OWNER` | OWNER | Critical operations (delete workspace, transfer ownership) |
|
||||
| `WORKSPACE_ADMIN` | OWNER, ADMIN | Administrative functions (manage members, settings) |
|
||||
| `WORKSPACE_MEMBER` | OWNER, ADMIN, MEMBER | Standard operations (create/edit content) |
|
||||
| `WORKSPACE_ANY` | All roles including GUEST | Read-only or basic access |
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
@Post('invite')
|
||||
@@ -109,6 +119,7 @@ async inviteMember(@Body() inviteDto: InviteDto) {
|
||||
Parameter decorator to extract the validated workspace ID.
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
async getTasks(@Workspace() workspaceId: string) {
|
||||
@@ -121,6 +132,7 @@ async getTasks(@Workspace() workspaceId: string) {
|
||||
Parameter decorator to extract the full workspace context.
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
async getTasks(@WorkspaceContext() workspace: { id: string }) {
|
||||
@@ -135,6 +147,7 @@ Located in `../auth/decorators/current-user.decorator.ts`
|
||||
Extracts the authenticated user from the request.
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Post()
|
||||
async create(@CurrentUser() user: any, @Body() dto: CreateDto) {
|
||||
@@ -153,7 +166,7 @@ import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
@Controller('resources')
|
||||
@Controller("resources")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class ResourcesController {
|
||||
@Get()
|
||||
@@ -164,17 +177,13 @@ export class ResourcesController {
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create(
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: any,
|
||||
@Body() dto: CreateDto
|
||||
) {
|
||||
async create(@Workspace() workspaceId: string, @CurrentUser() user: any, @Body() dto: CreateDto) {
|
||||
// Members and above can create
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async delete(@Param('id') id: string) {
|
||||
async delete(@Param("id") id: string) {
|
||||
// Only admins can delete
|
||||
}
|
||||
}
|
||||
@@ -185,24 +194,32 @@ export class ResourcesController {
|
||||
Different endpoints can have different permission requirements:
|
||||
|
||||
```typescript
|
||||
@Controller('projects')
|
||||
@Controller("projects")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class ProjectsController {
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async list() { /* Anyone can view */ }
|
||||
async list() {
|
||||
/* Anyone can view */
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create() { /* Members can create */ }
|
||||
async create() {
|
||||
/* Members can create */
|
||||
}
|
||||
|
||||
@Patch('settings')
|
||||
@Patch("settings")
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async updateSettings() { /* Only admins */ }
|
||||
async updateSettings() {
|
||||
/* Only admins */
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@RequirePermission(Permission.WORKSPACE_OWNER)
|
||||
async deleteProject() { /* Only owner */ }
|
||||
async deleteProject() {
|
||||
/* Only owner */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -211,17 +228,19 @@ export class ProjectsController {
|
||||
The workspace ID can be provided in multiple ways:
|
||||
|
||||
**Via Header (Recommended for SPAs):**
|
||||
|
||||
```typescript
|
||||
// Frontend
|
||||
fetch('/api/tasks', {
|
||||
fetch("/api/tasks", {
|
||||
headers: {
|
||||
'Authorization': 'Bearer <token>',
|
||||
'X-Workspace-Id': 'workspace-uuid',
|
||||
}
|
||||
})
|
||||
Authorization: "Bearer <token>",
|
||||
"X-Workspace-Id": "workspace-uuid",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Via URL Parameter:**
|
||||
|
||||
```typescript
|
||||
@Get(':workspaceId/tasks')
|
||||
async getTasks(@Param('workspaceId') workspaceId: string) {
|
||||
@@ -230,6 +249,7 @@ async getTasks(@Param('workspaceId') workspaceId: string) {
|
||||
```
|
||||
|
||||
**Via Request Body:**
|
||||
|
||||
```typescript
|
||||
@Post()
|
||||
async create(@Body() dto: { workspaceId: string; name: string }) {
|
||||
@@ -240,6 +260,7 @@ async create(@Body() dto: { workspaceId: string; name: string }) {
|
||||
## Row-Level Security (RLS)
|
||||
|
||||
When `WorkspaceGuard` is applied, it automatically:
|
||||
|
||||
1. Calls `setCurrentUser(userId)` to set the RLS context
|
||||
2. All subsequent database queries are automatically filtered by RLS policies
|
||||
3. Users can only access data in workspaces they're members of
|
||||
@@ -249,10 +270,12 @@ When `WorkspaceGuard` is applied, it automatically:
|
||||
## Testing
|
||||
|
||||
Tests are provided for both guards:
|
||||
|
||||
- `workspace.guard.spec.ts` - WorkspaceGuard tests
|
||||
- `permission.guard.spec.ts` - PermissionGuard tests
|
||||
|
||||
**Run tests:**
|
||||
|
||||
```bash
|
||||
npm test -- workspace.guard.spec
|
||||
npm test -- permission.guard.spec
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("BaseFilterDto", () => {
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.property === "sortOrder")).toBe(true);
|
||||
expect(errors.some((e) => e.property === "sortOrder")).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept comma-separated sortBy fields", async () => {
|
||||
@@ -134,7 +134,7 @@ describe("BaseFilterDto", () => {
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.property === "dateFrom")).toBe(true);
|
||||
expect(errors.some((e) => e.property === "dateFrom")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid date format for dateTo", async () => {
|
||||
@@ -144,7 +144,7 @@ describe("BaseFilterDto", () => {
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.property === "dateTo")).toBe(true);
|
||||
expect(errors.some((e) => e.property === "dateTo")).toBe(true);
|
||||
});
|
||||
|
||||
it("should trim whitespace from search query", async () => {
|
||||
@@ -165,6 +165,6 @@ describe("BaseFilterDto", () => {
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.property === "search")).toBe(true);
|
||||
expect(errors.some((e) => e.property === "search")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,10 +44,7 @@ describe("PermissionGuard", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (
|
||||
user: any,
|
||||
workspace: any
|
||||
): ExecutionContext => {
|
||||
const createMockExecutionContext = (user: any, workspace: any): ExecutionContext => {
|
||||
const mockRequest = {
|
||||
user,
|
||||
workspace,
|
||||
@@ -67,10 +64,7 @@ describe("PermissionGuard", () => {
|
||||
const workspaceId = "workspace-456";
|
||||
|
||||
it("should allow access when no permission is required", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(undefined);
|
||||
|
||||
@@ -80,10 +74,7 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should allow OWNER to access WORKSPACE_OWNER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
@@ -99,30 +90,19 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should deny ADMIN access to WORKSPACE_OWNER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should allow OWNER and ADMIN to access WORKSPACE_ADMIN permission", async () => {
|
||||
const context1 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context2 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context1 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
const context2 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
|
||||
|
||||
@@ -140,34 +120,20 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should deny MEMBER access to WORKSPACE_ADMIN permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should allow OWNER, ADMIN, and MEMBER to access WORKSPACE_MEMBER permission", async () => {
|
||||
const context1 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context2 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context3 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context1 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
const context2 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
const context3 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
@@ -191,26 +157,18 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should deny GUEST access to WORKSPACE_MEMBER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.GUEST,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should allow any role (including GUEST) to access WORKSPACE_ANY permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ANY);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
@@ -227,9 +185,7 @@ describe("PermissionGuard", () => {
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when workspace context is missing", async () => {
|
||||
@@ -237,42 +193,28 @@ describe("PermissionGuard", () => {
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not a workspace member", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"You are not a member of this workspace"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle database errors gracefully", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
|
||||
new Error("Database error")
|
||||
);
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,10 +58,7 @@ describe("WorkspaceGuard", () => {
|
||||
const workspaceId = "workspace-456";
|
||||
|
||||
it("should allow access when user is a workspace member (via header)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
@@ -87,11 +84,7 @@ describe("WorkspaceGuard", () => {
|
||||
});
|
||||
|
||||
it("should allow access when user is a workspace member (via URL param)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{},
|
||||
{ workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, {}, { workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
@@ -105,12 +98,7 @@ describe("WorkspaceGuard", () => {
|
||||
});
|
||||
|
||||
it("should allow access when user is a workspace member (via body)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{},
|
||||
{},
|
||||
{ workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, {}, {}, { workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
@@ -154,59 +142,38 @@ describe("WorkspaceGuard", () => {
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not authenticated", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
null,
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext(null, { "x-workspace-id": workspaceId });
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"User not authenticated"
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow("User not authenticated");
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when workspace ID is missing", async () => {
|
||||
const context = createMockExecutionContext({ id: userId });
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"Workspace ID is required"
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(BadRequestException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow("Workspace ID is required");
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not a workspace member", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"You do not have access to this workspace"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle database errors gracefully", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
|
||||
new Error("Database connection failed")
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,18 +27,14 @@ describe("QueryBuilder", () => {
|
||||
it("should handle single field", () => {
|
||||
const result = QueryBuilder.buildSearchFilter("test", ["title"]);
|
||||
expect(result).toEqual({
|
||||
OR: [
|
||||
{ title: { contains: "test", mode: "insensitive" } },
|
||||
],
|
||||
OR: [{ title: { contains: "test", mode: "insensitive" } }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should trim search query", () => {
|
||||
const result = QueryBuilder.buildSearchFilter(" test ", ["title"]);
|
||||
expect(result).toEqual({
|
||||
OR: [
|
||||
{ title: { contains: "test", mode: "insensitive" } },
|
||||
],
|
||||
OR: [{ title: { contains: "test", mode: "insensitive" } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -56,26 +52,17 @@ describe("QueryBuilder", () => {
|
||||
|
||||
it("should build multi-field sort", () => {
|
||||
const result = QueryBuilder.buildSortOrder("priority,dueDate", SortOrder.DESC);
|
||||
expect(result).toEqual([
|
||||
{ priority: "desc" },
|
||||
{ dueDate: "desc" },
|
||||
]);
|
||||
expect(result).toEqual([{ priority: "desc" }, { dueDate: "desc" }]);
|
||||
});
|
||||
|
||||
it("should handle mixed sorting with custom order per field", () => {
|
||||
const result = QueryBuilder.buildSortOrder("priority:asc,dueDate:desc");
|
||||
expect(result).toEqual([
|
||||
{ priority: "asc" },
|
||||
{ dueDate: "desc" },
|
||||
]);
|
||||
expect(result).toEqual([{ priority: "asc" }, { dueDate: "desc" }]);
|
||||
});
|
||||
|
||||
it("should use default order when not specified per field", () => {
|
||||
const result = QueryBuilder.buildSortOrder("priority,dueDate", SortOrder.ASC);
|
||||
expect(result).toEqual([
|
||||
{ priority: "asc" },
|
||||
{ dueDate: "asc" },
|
||||
]);
|
||||
expect(result).toEqual([{ priority: "asc" }, { dueDate: "asc" }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user