mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
feat(api): add attachment extra attributes
This commit is contained in:
282
api/src/attachment/guards/attachment-ability.guard.spec.ts
Normal file
282
api/src/attachment/guards/attachment-ability.guard.spec.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* Copyright © 2025 Hexastack. All rights reserved.
|
||||
*
|
||||
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
||||
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
|
||||
*/
|
||||
|
||||
import { BadRequestException, ExecutionContext } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { Model } from '@/user/schemas/model.schema';
|
||||
import { Permission } from '@/user/schemas/permission.schema';
|
||||
import { ModelService } from '@/user/services/model.service';
|
||||
import { PermissionService } from '@/user/services/permission.service';
|
||||
import { Action } from '@/user/types/action.type';
|
||||
|
||||
import { attachment } from '../mocks/attachment.mock';
|
||||
import { Attachment } from '../schemas/attachment.schema';
|
||||
import { AttachmentService } from '../services/attachment.service';
|
||||
|
||||
import { AttachmentGuard } from './attachment-ability.guard';
|
||||
|
||||
describe('AttachmentGuard', () => {
|
||||
let guard: AttachmentGuard;
|
||||
let permissionService: PermissionService;
|
||||
let modelService: ModelService;
|
||||
let attachmentService: AttachmentService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AttachmentGuard,
|
||||
{
|
||||
provide: PermissionService,
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: ModelService,
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: AttachmentService,
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<AttachmentGuard>(AttachmentGuard);
|
||||
permissionService = module.get<PermissionService>(PermissionService);
|
||||
modelService = module.get<ModelService>(ModelService);
|
||||
attachmentService = module.get<AttachmentService>(AttachmentService);
|
||||
});
|
||||
|
||||
describe('canActivate', () => {
|
||||
it('should allow GET requests with valid context', async () => {
|
||||
const mockUser = { roles: ['admin-id'] } as any;
|
||||
const mockContext = ['user_avatar'];
|
||||
|
||||
jest.spyOn(modelService, 'findOne').mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['user', 'attachment'].includes(criteria.identity)
|
||||
? Promise.reject('Invalid #1')
|
||||
: Promise.resolve({
|
||||
identity: criteria.identity,
|
||||
id: `${criteria.identity}-id`,
|
||||
} as Model);
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(permissionService, 'findOne')
|
||||
.mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['user-id', 'attachment-id'].includes(criteria.model) ||
|
||||
criteria.action !== Action.READ
|
||||
? Promise.reject('Invalid #2')
|
||||
: Promise.resolve({
|
||||
model: criteria.model,
|
||||
action: Action.READ,
|
||||
role: 'admin-id',
|
||||
} as Permission);
|
||||
});
|
||||
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
query: { where: { context: mockContext } },
|
||||
method: 'GET',
|
||||
user: mockUser,
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for GET requests with invalid context', async () => {
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
query: { where: { context: 'invalid_context' } },
|
||||
method: 'GET',
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
await expect(guard.canActivate(mockExecutionContext)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow GET requests with valid id', async () => {
|
||||
const mockUser = { roles: ['admin-id'] } as any;
|
||||
|
||||
jest
|
||||
.spyOn(attachmentService, 'findOne')
|
||||
.mockImplementation((criteria) => {
|
||||
return criteria !== '9'.repeat(24)
|
||||
? Promise.reject('Invalid ID')
|
||||
: Promise.resolve({
|
||||
id: '9'.repeat(24),
|
||||
context: `user_avatar`,
|
||||
} as Attachment);
|
||||
});
|
||||
|
||||
jest.spyOn(modelService, 'findOne').mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['user', 'attachment'].includes(criteria.identity)
|
||||
? Promise.reject('Invalid #1')
|
||||
: Promise.resolve({
|
||||
identity: criteria.identity,
|
||||
id: `${criteria.identity}-id`,
|
||||
} as Model);
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(permissionService, 'findOne')
|
||||
.mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['user-id', 'attachment-id'].includes(criteria.model) ||
|
||||
criteria.action !== Action.READ
|
||||
? Promise.reject('Invalid #2')
|
||||
: Promise.resolve({
|
||||
model: criteria.model,
|
||||
action: Action.READ,
|
||||
role: 'admin-id',
|
||||
} as Permission);
|
||||
});
|
||||
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
params: { id: '9'.repeat(24) },
|
||||
method: 'GET',
|
||||
user: mockUser,
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow POST requests with valid context', async () => {
|
||||
const mockUser = { roles: ['editor-id'] } as any;
|
||||
|
||||
jest.spyOn(modelService, 'findOne').mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['block', 'attachment'].includes(criteria.identity)
|
||||
? Promise.reject()
|
||||
: Promise.resolve({
|
||||
identity: criteria.identity,
|
||||
id: `${criteria.identity}-id`,
|
||||
} as Model);
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(permissionService, 'findOne')
|
||||
.mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['block-id', 'attachment-id'].includes(criteria.model)
|
||||
? Promise.reject()
|
||||
: Promise.resolve({
|
||||
model: criteria.model,
|
||||
action: Action.CREATE,
|
||||
role: 'editor-id',
|
||||
} as Permission);
|
||||
});
|
||||
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
query: { context: 'block_attachment' },
|
||||
method: 'POST',
|
||||
user: mockUser,
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for DELETE requests with invalid attachment ID', async () => {
|
||||
jest.spyOn(attachmentService, 'findOne').mockResolvedValue(null);
|
||||
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
method: 'DELETE',
|
||||
params: { id: 'invalid-id' },
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
await expect(guard.canActivate(mockExecutionContext)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow DELETE requests with valid attachment and context', async () => {
|
||||
const mockUser = { roles: ['admin-id'] } as any;
|
||||
|
||||
jest.spyOn(attachmentService, 'findOne').mockResolvedValue(attachment);
|
||||
|
||||
jest.spyOn(modelService, 'findOne').mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['block', 'attachment'].includes(criteria.identity)
|
||||
? Promise.reject('Invalid X')
|
||||
: Promise.resolve({
|
||||
identity: criteria.identity,
|
||||
id: `${criteria.identity}-id`,
|
||||
} as Model);
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(permissionService, 'findOne')
|
||||
.mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['block-id', 'attachment-id'].includes(criteria.model) ||
|
||||
(criteria.model === 'block-id' &&
|
||||
criteria.action !== Action.UPDATE) ||
|
||||
(criteria.model === 'attachment-id' &&
|
||||
criteria.action !== Action.DELETE)
|
||||
? Promise.reject('Invalid Y')
|
||||
: Promise.resolve({
|
||||
model: criteria.model,
|
||||
action: criteria.action,
|
||||
role: 'admin-id',
|
||||
} as Permission);
|
||||
});
|
||||
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
method: 'DELETE',
|
||||
params: { id: attachment.id },
|
||||
user: mockUser,
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for unsupported HTTP methods', async () => {
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
method: 'PUT',
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
await expect(guard.canActivate(mockExecutionContext)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
265
api/src/attachment/guards/attachment-ability.guard.ts
Normal file
265
api/src/attachment/guards/attachment-ability.guard.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* Copyright © 2025 Hexastack. All rights reserved.
|
||||
*
|
||||
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
||||
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
|
||||
*/
|
||||
|
||||
import { Url } from 'url';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { Types } from 'mongoose';
|
||||
import qs from 'qs';
|
||||
|
||||
import { User } from '@/user/schemas/user.schema';
|
||||
import { ModelService } from '@/user/services/model.service';
|
||||
import { PermissionService } from '@/user/services/permission.service';
|
||||
import { Action } from '@/user/types/action.type';
|
||||
import { TModel } from '@/user/types/model.type';
|
||||
|
||||
import { AttachmentService } from '../services/attachment.service';
|
||||
import { TAttachmentContext } from '../types';
|
||||
import { isAttachmentContext, isAttachmentContextArray } from '../utilities';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly permissionService: PermissionService,
|
||||
private readonly modelService: ModelService,
|
||||
private readonly attachmentService: AttachmentService,
|
||||
) {}
|
||||
|
||||
private permissionMap: Record<
|
||||
Action,
|
||||
Record<TAttachmentContext, [TModel, Action][]>
|
||||
> = {
|
||||
// Read attachments by context
|
||||
[Action.READ]: {
|
||||
setting_attachment: [
|
||||
['setting', Action.READ],
|
||||
['attachment', Action.READ],
|
||||
],
|
||||
user_avatar: [['user', Action.READ]],
|
||||
block_attachment: [
|
||||
['block', Action.READ],
|
||||
['attachment', Action.READ],
|
||||
],
|
||||
content_attachment: [
|
||||
['content', Action.READ],
|
||||
['attachment', Action.READ],
|
||||
],
|
||||
subscriber_avatar: [['subscriber', Action.READ]],
|
||||
message_attachment: [
|
||||
['message', Action.READ],
|
||||
['attachment', Action.READ],
|
||||
],
|
||||
},
|
||||
// Create attachments by context
|
||||
[Action.CREATE]: {
|
||||
setting_attachment: [
|
||||
['setting', Action.UPDATE],
|
||||
['attachment', Action.CREATE],
|
||||
],
|
||||
user_avatar: [
|
||||
// Not authorized, done via /user/:id/edit endpoint
|
||||
],
|
||||
block_attachment: [
|
||||
['block', Action.UPDATE],
|
||||
['attachment', Action.CREATE],
|
||||
],
|
||||
content_attachment: [
|
||||
['content', Action.UPDATE],
|
||||
['attachment', Action.CREATE],
|
||||
],
|
||||
subscriber_avatar: [
|
||||
// Not authorized, done programmatically by the channel
|
||||
],
|
||||
message_attachment: [
|
||||
// Unless we're in case of a handover, done programmatically by the channel
|
||||
['message', Action.CREATE],
|
||||
['attachment', Action.CREATE],
|
||||
],
|
||||
},
|
||||
// Delete attachments by context
|
||||
[Action.DELETE]: {
|
||||
setting_attachment: [
|
||||
['setting', Action.UPDATE],
|
||||
['attachment', Action.DELETE],
|
||||
],
|
||||
user_avatar: [
|
||||
// Not authorized
|
||||
],
|
||||
block_attachment: [
|
||||
['block', Action.UPDATE],
|
||||
['attachment', Action.DELETE],
|
||||
],
|
||||
content_attachment: [
|
||||
['content', Action.UPDATE],
|
||||
['attachment', Action.DELETE],
|
||||
],
|
||||
subscriber_avatar: [
|
||||
// Not authorized, done programmatically by the channel
|
||||
],
|
||||
message_attachment: [
|
||||
// Not authorized
|
||||
],
|
||||
},
|
||||
// Update attachments is not possible
|
||||
[Action.UPDATE]: {
|
||||
setting_attachment: [],
|
||||
user_avatar: [],
|
||||
block_attachment: [],
|
||||
content_attachment: [],
|
||||
subscriber_avatar: [],
|
||||
message_attachment: [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the user has the required permission for a given action and model.
|
||||
*
|
||||
* @param user - The current authenticated user.
|
||||
* @param identity - The model identity being accessed.
|
||||
* @param action - The action being performed (e.g., CREATE, READ).
|
||||
* @returns A promise that resolves to `true` if the user has the required permission, otherwise `false`.
|
||||
*/
|
||||
private async hasPermission(
|
||||
user: Express.User & User,
|
||||
identity: TModel,
|
||||
action?: Action,
|
||||
) {
|
||||
if (Array.isArray(user?.roles)) {
|
||||
for (const role of user.roles) {
|
||||
const modelObj = await this.modelService.findOne({ identity });
|
||||
if (modelObj) {
|
||||
const { id: model } = modelObj;
|
||||
const hasRequiredPermission = await this.permissionService.findOne({
|
||||
action,
|
||||
role,
|
||||
model,
|
||||
});
|
||||
|
||||
return !!hasRequiredPermission;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is authorized to perform a given action on a attachment based on its context and user roles.
|
||||
*
|
||||
* @param action - The action on the attachment.
|
||||
* @param user - The current user.
|
||||
* @param context - The context of the attachment (e.g., user_avatar, setting_attachment).
|
||||
* @returns A promise that resolves to `true` if the user has the required upload permission, otherwise `false`.
|
||||
*/
|
||||
private async isAuthorized(
|
||||
action: Action,
|
||||
user: Express.User & User,
|
||||
context: TAttachmentContext,
|
||||
): Promise<boolean> {
|
||||
if (!action) {
|
||||
throw new TypeError('Invalid action');
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
throw new TypeError('Invalid context');
|
||||
}
|
||||
|
||||
const permissions = this.permissionMap[action][context];
|
||||
|
||||
if (!permissions.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
await Promise.all(
|
||||
permissions.map(([identity, action]) =>
|
||||
this.hasPermission(user, identity, action),
|
||||
),
|
||||
)
|
||||
).every(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the user is authorized to perform the requested action.
|
||||
*
|
||||
* @param ctx - The execution context, providing details of the
|
||||
* incoming HTTP request and user information.
|
||||
*
|
||||
* @returns Returns `true` if the user is authorized, otherwise throws an exception.
|
||||
*/
|
||||
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
||||
const { query, _parsedUrl, method, user, params } = ctx
|
||||
.switchToHttp()
|
||||
.getRequest<Request & { user: User; _parsedUrl: Url }>();
|
||||
|
||||
switch (method) {
|
||||
// count(), find() and findOne() endpoints
|
||||
case 'GET': {
|
||||
if (params && 'id' in params && Types.ObjectId.isValid(params.id)) {
|
||||
const attachment = await this.attachmentService.findOne(params.id);
|
||||
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Attachment not found!');
|
||||
}
|
||||
|
||||
return await this.isAuthorized(Action.READ, user, attachment.context);
|
||||
} else if (query.where) {
|
||||
const { context = [] } = query.where as qs.ParsedQs;
|
||||
|
||||
if (!isAttachmentContextArray(context)) {
|
||||
throw new BadRequestException('Invalid context param');
|
||||
}
|
||||
|
||||
return (
|
||||
await Promise.all(
|
||||
context.map((c) => this.isAuthorized(Action.READ, user, c)),
|
||||
)
|
||||
).every(Boolean);
|
||||
} else {
|
||||
throw new BadRequestException('Invalid params');
|
||||
}
|
||||
}
|
||||
// upload() endpoint
|
||||
case 'POST': {
|
||||
const { context = '' } = query;
|
||||
if (!isAttachmentContext(context)) {
|
||||
throw new BadRequestException('Invalid context param');
|
||||
}
|
||||
|
||||
return await this.isAuthorized(Action.CREATE, user, context);
|
||||
}
|
||||
// deleteOne() endpoint
|
||||
case 'DELETE': {
|
||||
if (params && 'id' in params && Types.ObjectId.isValid(params.id)) {
|
||||
const attachment = await this.attachmentService.findOne(params.id);
|
||||
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Invalid attachment ID');
|
||||
}
|
||||
|
||||
return await this.isAuthorized(
|
||||
Action.DELETE,
|
||||
user,
|
||||
attachment.context,
|
||||
);
|
||||
} else {
|
||||
throw new BadRequestException('Invalid params');
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new BadRequestException('Invalid operation');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user