cff17db6cb
## Overview This PR strengthens our permission system by introducing more granular role-based access control across the platform. ## Changes ### New Permissions Added - **Applications** - Control who can install and manage applications - **Layouts** - Control who can customize page layouts and UI structure - **AI** - Control access to AI features and agents - **Upload File** - Separate permission for file uploads - **Download File** - Separate permission for file downloads (frontend visibility) ### Security Enhancements - Implemented whitelist-based validation for workspace field updates - Added explicit permission guards to core entity resolvers - Enhanced ESLint rule to enforce permission checks on all mutations - Created `CustomPermissionGuard` and `NoPermissionGuard` for better code documentation ### Affected Components - Core entity resolvers: webhooks, files, domains, applications, layouts, postgres credentials - Workspace update mutations now use whitelist validation - Settings UI updated with new permission controls ### Developer Experience - ESLint now catches missing permission guards during development - Explicit guard markers make permission requirements clear in code review - Comprehensive test coverage for new permission logic ## Testing - ✅ All TypeScript type checks pass - ✅ ESLint validation passes - ✅ New permission guards properly enforced - ✅ Frontend UI displays new permissions correctly ## Migration Notes Existing workspaces will need to assign the new permissions to roles as needed. By default, all new permissions are set to `false` for non-admin roles.
511 lines
15 KiB
TypeScript
511 lines
15 KiB
TypeScript
import { Test, type TestingModule } from '@nestjs/testing';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
|
|
import { type Repository } from 'typeorm';
|
|
|
|
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
|
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
|
import { ViewOpenRecordIn } from 'src/engine/metadata-modules/view/enums/view-open-record-in';
|
|
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
|
|
import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum';
|
|
import {
|
|
ViewException,
|
|
ViewExceptionCode,
|
|
ViewExceptionMessageKey,
|
|
generateViewExceptionMessage,
|
|
generateViewUserFriendlyExceptionMessage,
|
|
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
|
|
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
|
|
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
|
|
|
describe('ViewService', () => {
|
|
let viewService: ViewService;
|
|
let viewRepository: Repository<ViewEntity>;
|
|
let i18nService: I18nService;
|
|
|
|
const mockView = {
|
|
id: 'view-id',
|
|
name: 'Test View',
|
|
objectMetadataId: 'object-id',
|
|
workspaceId: 'workspace-id',
|
|
type: ViewType.TABLE,
|
|
icon: 'test-icon',
|
|
position: 0,
|
|
isCompact: false,
|
|
isCustom: true,
|
|
key: 'INDEX',
|
|
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
|
|
kanbanAggregateOperation: null,
|
|
kanbanAggregateOperationFieldMetadataId: null,
|
|
anyFieldFilterValue: null,
|
|
visibility: ViewVisibility.WORKSPACE,
|
|
createdByUserWorkspaceId: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
deletedAt: null,
|
|
} as ViewEntity;
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
ViewService,
|
|
{
|
|
provide: getRepositoryToken(ViewEntity),
|
|
useValue: {
|
|
find: jest.fn(),
|
|
findOne: jest.fn(),
|
|
create: jest.fn(),
|
|
save: jest.fn(),
|
|
softDelete: jest.fn(),
|
|
delete: jest.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: WorkspaceCacheStorageService,
|
|
useValue: {
|
|
flushGraphQLOperation: jest.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: I18nService,
|
|
useValue: {
|
|
translateMessage: jest.fn(),
|
|
},
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
viewService = module.get<ViewService>(ViewService);
|
|
viewRepository = module.get<Repository<ViewEntity>>(
|
|
getRepositoryToken(ViewEntity),
|
|
);
|
|
i18nService = module.get<I18nService>(I18nService);
|
|
});
|
|
|
|
it('should be defined', () => {
|
|
expect(viewService).toBeDefined();
|
|
});
|
|
|
|
describe('findByWorkspaceId', () => {
|
|
it('should return workspace views and user-owned unlisted views', async () => {
|
|
const workspaceId = 'workspace-id';
|
|
const userWorkspaceId = 'user-workspace-id';
|
|
const workspaceView = {
|
|
...mockView,
|
|
id: 'workspace-view',
|
|
visibility: ViewVisibility.WORKSPACE,
|
|
} as ViewEntity;
|
|
const userUnlistedView = {
|
|
...mockView,
|
|
id: 'user-unlisted-view',
|
|
visibility: ViewVisibility.UNLISTED,
|
|
createdByUserWorkspaceId: userWorkspaceId,
|
|
} as ViewEntity;
|
|
const otherUserUnlistedView = {
|
|
...mockView,
|
|
id: 'other-user-unlisted-view',
|
|
visibility: ViewVisibility.UNLISTED,
|
|
createdByUserWorkspaceId: 'other-user-workspace-id',
|
|
} as ViewEntity;
|
|
const allViews = [workspaceView, userUnlistedView, otherUserUnlistedView];
|
|
|
|
jest.spyOn(viewRepository, 'find').mockResolvedValue(allViews);
|
|
|
|
const result = await viewService.findByWorkspaceId(
|
|
workspaceId,
|
|
userWorkspaceId,
|
|
);
|
|
|
|
expect(viewRepository.find).toHaveBeenCalledWith({
|
|
where: {
|
|
workspaceId,
|
|
deletedAt: expect.anything(),
|
|
},
|
|
order: { position: 'ASC' },
|
|
relations: [
|
|
'workspace',
|
|
'viewFields',
|
|
'viewFilters',
|
|
'viewSorts',
|
|
'viewGroups',
|
|
'viewFilterGroups',
|
|
],
|
|
});
|
|
expect(result).toEqual([workspaceView, userUnlistedView]);
|
|
expect(result).not.toContain(otherUserUnlistedView);
|
|
});
|
|
|
|
it('should return only workspace views when no userWorkspaceId provided', async () => {
|
|
const workspaceId = 'workspace-id';
|
|
const workspaceView = {
|
|
...mockView,
|
|
id: 'workspace-view',
|
|
visibility: ViewVisibility.WORKSPACE,
|
|
} as ViewEntity;
|
|
const unlistedView = {
|
|
...mockView,
|
|
id: 'unlisted-view',
|
|
visibility: ViewVisibility.UNLISTED,
|
|
createdByUserWorkspaceId: 'some-user-workspace-id',
|
|
} as ViewEntity;
|
|
const allViews = [workspaceView, unlistedView];
|
|
|
|
jest.spyOn(viewRepository, 'find').mockResolvedValue(allViews);
|
|
|
|
const result = await viewService.findByWorkspaceId(workspaceId);
|
|
|
|
expect(result).toEqual([workspaceView]);
|
|
expect(result).not.toContain(unlistedView);
|
|
});
|
|
});
|
|
|
|
describe('findByObjectMetadataId', () => {
|
|
it('should return workspace views and user-owned unlisted views for an object', async () => {
|
|
const workspaceId = 'workspace-id';
|
|
const objectMetadataId = 'object-id';
|
|
const userWorkspaceId = 'user-workspace-id';
|
|
const workspaceView = {
|
|
...mockView,
|
|
id: 'workspace-view',
|
|
visibility: ViewVisibility.WORKSPACE,
|
|
objectMetadataId,
|
|
} as ViewEntity;
|
|
const userUnlistedView = {
|
|
...mockView,
|
|
id: 'user-unlisted-view',
|
|
visibility: ViewVisibility.UNLISTED,
|
|
createdByUserWorkspaceId: userWorkspaceId,
|
|
objectMetadataId,
|
|
} as ViewEntity;
|
|
const otherUserUnlistedView = {
|
|
...mockView,
|
|
id: 'other-user-unlisted-view',
|
|
visibility: ViewVisibility.UNLISTED,
|
|
createdByUserWorkspaceId: 'other-user-workspace-id',
|
|
objectMetadataId,
|
|
} as ViewEntity;
|
|
const allViews = [workspaceView, userUnlistedView, otherUserUnlistedView];
|
|
|
|
jest.spyOn(viewRepository, 'find').mockResolvedValue(allViews);
|
|
|
|
const result = await viewService.findByObjectMetadataId(
|
|
workspaceId,
|
|
objectMetadataId,
|
|
userWorkspaceId,
|
|
);
|
|
|
|
expect(viewRepository.find).toHaveBeenCalledWith({
|
|
where: {
|
|
workspaceId,
|
|
objectMetadataId,
|
|
deletedAt: expect.anything(),
|
|
},
|
|
order: { position: 'ASC' },
|
|
relations: [
|
|
'workspace',
|
|
'viewFields',
|
|
'viewFilters',
|
|
'viewSorts',
|
|
'viewGroups',
|
|
'viewFilterGroups',
|
|
],
|
|
});
|
|
expect(result).toEqual([workspaceView, userUnlistedView]);
|
|
expect(result).not.toContain(otherUserUnlistedView);
|
|
});
|
|
|
|
it('should return only workspace views when no userWorkspaceId provided', async () => {
|
|
const workspaceId = 'workspace-id';
|
|
const objectMetadataId = 'object-id';
|
|
const workspaceView = {
|
|
...mockView,
|
|
id: 'workspace-view',
|
|
visibility: ViewVisibility.WORKSPACE,
|
|
objectMetadataId,
|
|
} as ViewEntity;
|
|
const unlistedView = {
|
|
...mockView,
|
|
id: 'unlisted-view',
|
|
visibility: ViewVisibility.UNLISTED,
|
|
createdByUserWorkspaceId: 'some-user-workspace-id',
|
|
objectMetadataId,
|
|
} as ViewEntity;
|
|
const allViews = [workspaceView, unlistedView];
|
|
|
|
jest.spyOn(viewRepository, 'find').mockResolvedValue(allViews);
|
|
|
|
const result = await viewService.findByObjectMetadataId(
|
|
workspaceId,
|
|
objectMetadataId,
|
|
);
|
|
|
|
expect(result).toEqual([workspaceView]);
|
|
expect(result).not.toContain(unlistedView);
|
|
});
|
|
});
|
|
|
|
describe('findById', () => {
|
|
it('should return a view by id', async () => {
|
|
const id = 'view-id';
|
|
const workspaceId = 'workspace-id';
|
|
|
|
jest.spyOn(viewRepository, 'findOne').mockResolvedValue(mockView);
|
|
|
|
const result = await viewService.findById(id, workspaceId);
|
|
|
|
expect(viewRepository.findOne).toHaveBeenCalledWith({
|
|
where: {
|
|
id,
|
|
workspaceId,
|
|
deletedAt: expect.anything(),
|
|
},
|
|
relations: [
|
|
'workspace',
|
|
'viewFields',
|
|
'viewFilters',
|
|
'viewSorts',
|
|
'viewGroups',
|
|
'viewFilterGroups',
|
|
],
|
|
});
|
|
expect(result).toEqual(mockView);
|
|
});
|
|
|
|
it('should return null when view is not found', async () => {
|
|
const id = 'non-existent-id';
|
|
const workspaceId = 'workspace-id';
|
|
|
|
jest.spyOn(viewRepository, 'findOne').mockResolvedValue(null);
|
|
|
|
const result = await viewService.findById(id, workspaceId);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('create', () => {
|
|
const validViewData = {
|
|
name: 'Test View',
|
|
workspaceId: 'workspace-id',
|
|
objectMetadataId: 'object-id',
|
|
type: ViewType.TABLE,
|
|
icon: 'test-icon',
|
|
};
|
|
|
|
it('should create a view successfully', async () => {
|
|
jest.spyOn(viewRepository, 'create').mockReturnValue(mockView);
|
|
jest.spyOn(viewRepository, 'save').mockResolvedValue(mockView);
|
|
|
|
const result = await viewService.create(validViewData);
|
|
|
|
expect(viewRepository.create).toHaveBeenCalledWith({
|
|
...validViewData,
|
|
isCustom: true,
|
|
});
|
|
expect(viewRepository.save).toHaveBeenCalledWith(mockView);
|
|
expect(result).toEqual(mockView);
|
|
});
|
|
|
|
it('should throw exception when workspaceId is missing', async () => {
|
|
const invalidData = { ...validViewData, workspaceId: undefined };
|
|
|
|
await expect(viewService.create(invalidData)).rejects.toThrow(
|
|
new ViewException(
|
|
generateViewExceptionMessage(
|
|
ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED,
|
|
),
|
|
ViewExceptionCode.INVALID_VIEW_DATA,
|
|
{
|
|
userFriendlyMessage: generateViewUserFriendlyExceptionMessage(
|
|
ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED,
|
|
),
|
|
},
|
|
),
|
|
);
|
|
});
|
|
|
|
it('should throw exception when objectMetadataId is missing', async () => {
|
|
const invalidData = { ...validViewData, objectMetadataId: undefined };
|
|
|
|
await expect(viewService.create(invalidData)).rejects.toThrow(
|
|
new ViewException(
|
|
generateViewExceptionMessage(
|
|
ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED,
|
|
),
|
|
ViewExceptionCode.INVALID_VIEW_DATA,
|
|
{
|
|
userFriendlyMessage: generateViewUserFriendlyExceptionMessage(
|
|
ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED,
|
|
),
|
|
},
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('should update a view successfully', async () => {
|
|
const id = 'view-id';
|
|
const workspaceId = 'workspace-id';
|
|
const updateData = { name: 'Updated View' };
|
|
const updatedView = { ...mockView, ...updateData };
|
|
|
|
jest.spyOn(viewService, 'findById').mockResolvedValue(mockView);
|
|
jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView);
|
|
|
|
const result = await viewService.update(id, workspaceId, updateData);
|
|
|
|
expect(viewService.findById).toHaveBeenCalledWith(id, workspaceId);
|
|
expect(viewRepository.save).toHaveBeenCalledWith({
|
|
id,
|
|
...updateData,
|
|
});
|
|
expect(result).toEqual({ ...mockView, ...updatedView });
|
|
});
|
|
|
|
it('should throw exception when view is not found', async () => {
|
|
const id = 'non-existent-id';
|
|
const workspaceId = 'workspace-id';
|
|
const updateData = { name: 'Updated View' };
|
|
|
|
jest.spyOn(viewService, 'findById').mockResolvedValue(null);
|
|
|
|
await expect(
|
|
viewService.update(id, workspaceId, updateData),
|
|
).rejects.toThrow(
|
|
new ViewException(
|
|
generateViewExceptionMessage(
|
|
ViewExceptionMessageKey.VIEW_NOT_FOUND,
|
|
id,
|
|
),
|
|
ViewExceptionCode.VIEW_NOT_FOUND,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('should delete a view successfully', async () => {
|
|
const id = 'view-id';
|
|
const workspaceId = 'workspace-id';
|
|
|
|
jest.spyOn(viewService, 'findById').mockResolvedValue(mockView);
|
|
jest.spyOn(viewRepository, 'softDelete').mockResolvedValue({} as any);
|
|
|
|
const result = await viewService.delete(id, workspaceId);
|
|
|
|
expect(viewService.findById).toHaveBeenCalledWith(id, workspaceId);
|
|
expect(viewRepository.softDelete).toHaveBeenCalledWith(id);
|
|
expect(result).toEqual(mockView);
|
|
});
|
|
|
|
it('should throw exception when view is not found', async () => {
|
|
const id = 'non-existent-id';
|
|
const workspaceId = 'workspace-id';
|
|
|
|
jest.spyOn(viewService, 'findById').mockResolvedValue(null);
|
|
|
|
await expect(viewService.delete(id, workspaceId)).rejects.toThrow(
|
|
new ViewException(
|
|
generateViewExceptionMessage(
|
|
ViewExceptionMessageKey.VIEW_NOT_FOUND,
|
|
id,
|
|
),
|
|
ViewExceptionCode.VIEW_NOT_FOUND,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('destroy', () => {
|
|
it('should destroy a view successfully', async () => {
|
|
const id = 'view-id';
|
|
const workspaceId = 'workspace-id';
|
|
|
|
jest
|
|
.spyOn(viewService, 'findByIdIncludingDeleted')
|
|
.mockResolvedValue(mockView);
|
|
jest.spyOn(viewRepository, 'delete').mockResolvedValue({} as any);
|
|
|
|
const result = await viewService.destroy(id, workspaceId);
|
|
|
|
expect(viewService.findByIdIncludingDeleted).toHaveBeenCalledWith(
|
|
id,
|
|
workspaceId,
|
|
);
|
|
expect(viewRepository.delete).toHaveBeenCalledWith(id);
|
|
expect(result).toEqual(true);
|
|
});
|
|
});
|
|
|
|
describe('processViewNameWithTemplate', () => {
|
|
it('should replace template with objectLabelPlural', () => {
|
|
const viewName = 'All {objectLabelPlural}';
|
|
const objectLabelPlural = 'Companies';
|
|
|
|
jest.spyOn(i18nService, 'translateMessage').mockImplementation((args) => {
|
|
return args.messageId;
|
|
});
|
|
|
|
const result = viewService.processViewNameWithTemplate(
|
|
viewName,
|
|
false,
|
|
objectLabelPlural,
|
|
'en',
|
|
);
|
|
|
|
expect(result).toBe('All Companies');
|
|
});
|
|
|
|
it('should return translated value when translation exists', () => {
|
|
const viewName = 'All {objectLabelPlural}';
|
|
const objectLabelPlural = 'Companies';
|
|
const translatedTemplate = 'Toutes les Companies';
|
|
|
|
jest
|
|
.spyOn(i18nService, 'translateMessage')
|
|
.mockReturnValue(translatedTemplate);
|
|
|
|
const result = viewService.processViewNameWithTemplate(
|
|
viewName,
|
|
false,
|
|
objectLabelPlural,
|
|
'fr-FR',
|
|
);
|
|
|
|
expect(result).toBe(translatedTemplate);
|
|
});
|
|
|
|
it('should not translate custom views', () => {
|
|
const viewName = 'My Custom View';
|
|
|
|
const result = viewService.processViewNameWithTemplate(
|
|
viewName,
|
|
true,
|
|
undefined,
|
|
'en',
|
|
);
|
|
|
|
expect(i18nService.translateMessage).not.toHaveBeenCalled();
|
|
expect(result).toBe(viewName);
|
|
});
|
|
|
|
it('should return original name when no objectLabelPlural provided for template', () => {
|
|
const viewName = 'All {objectLabelPlural}';
|
|
|
|
jest.spyOn(i18nService, 'translateMessage').mockImplementation((args) => {
|
|
return args.messageId;
|
|
});
|
|
|
|
const result = viewService.processViewNameWithTemplate(
|
|
viewName,
|
|
false,
|
|
undefined,
|
|
'en',
|
|
);
|
|
|
|
expect(result).toBe(viewName);
|
|
});
|
|
});
|
|
});
|