feat(api): add attachment extra attributes

This commit is contained in:
Mohamed Marrouchi
2025-01-08 16:42:46 +01:00
parent 47e8056a15
commit 994c8857e9
16 changed files with 872 additions and 109 deletions

View 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,
);
});
});
});

View 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');
}
}
}