teableio/teable

View on GitHub
apps/nestjs-backend/src/features/auth/permission.service.spec.ts

Summary

Maintainability
A
0 mins
Test Coverage
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import type { Action } from '@teable/core';
import { Role, getPermissions } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { noop } from 'lodash';
import { ClsService } from 'nestjs-cls';
import type { DeepMockProxy } from 'vitest-mock-extended';
import { mockDeep, mockReset } from 'vitest-mock-extended';
import type { IClsStore } from '../../types/cls';
import { PermissionService } from './permission.service';

describe('PermissionService', () => {
  let service: PermissionService;
  let prismaServiceMock: DeepMockProxy<PrismaService>;
  let clsServiceMock: DeepMockProxy<ClsService<IClsStore>>;

  beforeEach(async () => {
    prismaServiceMock = mockDeep<PrismaService>();
    clsServiceMock = mockDeep<ClsService<IClsStore>>();

    const module: TestingModule = await Test.createTestingModule({
      providers: [PermissionService, PrismaService, ClsService],
    })
      .overrideProvider(PrismaService)
      .useValue(prismaServiceMock)
      .overrideProvider(ClsService)
      .useValue(clsServiceMock)
      .compile();

    service = module.get<PermissionService>(PermissionService);
  });

  afterEach(() => {
    mockReset(prismaServiceMock);
    mockReset(clsServiceMock);
  });

  describe('getRoleBySpaceId', () => {
    it('should return a SpaceRole', async () => {
      const spaceId = 'space-id';
      const roleName = 'space-role';
      prismaServiceMock.collaborator.findFirst.mockResolvedValue({ roleName } as any);
      const result = await service['getRoleBySpaceId'](spaceId);
      expect(result).toBe(roleName);
    });

    it('should throw a ForbiddenException if collaborator is not found', async () => {
      const spaceId = 'space-id';
      prismaServiceMock.collaborator.findFirst.mockResolvedValue(null);
      await expect(service['getRoleBySpaceId'](spaceId)).rejects.toThrowError(
        new ForbiddenException(`you have no permission to access this space`)
      );
    });
  });

  describe('getRoleByBaseId', () => {
    it('should return a BaseRole', async () => {
      const baseId = 'base-id';
      const roleName = 'base-role';
      prismaServiceMock.collaborator.findFirst.mockResolvedValue({ roleName } as any);
      const result = await service['getRoleByBaseId'](baseId);
      expect(result).toBe(roleName);
    });

    it('should return null if collaborator is not found', async () => {
      const baseId = 'base-id';
      prismaServiceMock.collaborator.findFirst.mockResolvedValue(null);
      const result = await service['getRoleByBaseId'](baseId);
      expect(result).toBeNull();
    });
  });

  describe('getPermissionsByResourceId', () => {
    it('should return permissions for a space resource', async () => {
      const resourceId = 'spcxxxxxxxx';
      vi.spyOn(service as any, 'getPermissionBySpaceId').mockImplementation(noop);
      await service.getPermissionsByResourceId(resourceId);
      expect(service['getPermissionBySpaceId']).toHaveBeenCalledWith(resourceId);
    });

    it('should return permissions for a base resource', async () => {
      const resourceId = 'bsexxxxxx';
      vi.spyOn(service as any, 'getPermissionByBaseId').mockImplementation(noop);
      await service.getPermissionsByResourceId(resourceId);
      expect(service['getPermissionByBaseId']).toHaveBeenCalledWith(resourceId, undefined);
    });

    it('should return permissions for a table resource', async () => {
      const resourceId = 'tblxxxxxxx';
      vi.spyOn(service as any, 'getPermissionByTableId').mockImplementation(noop);
      await service.getPermissionsByResourceId(resourceId);
      expect(service['getPermissionByTableId']).toHaveBeenCalledWith(resourceId, undefined);
    });

    it('should throw an error if resource is not found', async () => {
      const resourceId = 'invalid-id';
      await expect(service.getPermissionsByResourceId(resourceId)).rejects.toThrowError(
        new ForbiddenException('request path is not valid')
      );
    });
  });

  describe('getUpperIdByBaseId', () => {
    it('should return spaceId when valid baseId is provided', async () => {
      const baseId = 'bsexxxxxxxx';
      const spaceId = 'spcxxxxxxxxx';

      prismaServiceMock.base.findFirst.mockResolvedValueOnce({ spaceId } as any);
      const result = await service['getUpperIdByBaseId'](baseId);
      expect(result).toEqual({ spaceId });
    });

    it('should throw NotFoundException when invalid baseId is provided', async () => {
      const baseId = 'bsexxxxxxxx';

      prismaServiceMock.base.findFirst.mockResolvedValueOnce(null);

      await expect(service['getUpperIdByBaseId'](baseId)).rejects.toThrowError(NotFoundException);
    });
  });

  describe('isBaseIdAllowedForResource', () => {
    it('should return true when baseId is allowed for the resource', async () => {
      const baseId = 'bsexxxxxxxxx';
      const spaceIds = ['spcxxxxxxx'];
      const baseIds = ['bsexxxxxxxxx'];

      vi.spyOn(service as any, 'getUpperIdByBaseId').mockResolvedValueOnce({
        spaceId: 'spcxxxxxxx',
      });

      const result = await service['isBaseIdAllowedForResource'](baseId, spaceIds, baseIds);

      expect(result).toBe(true);
    });

    it('should return false when baseId is not allowed for the resource', async () => {
      const baseId = 'invalidBaseId';
      const spaceIds = ['spcxxxxxxx'];
      const baseIds = ['bsexxxxxxxxx'];

      vi.spyOn(service as any, 'getUpperIdByBaseId').mockResolvedValueOnce({
        spaceId: 'spc222222222',
      });

      const result = await service['isBaseIdAllowedForResource'](baseId, spaceIds, baseIds);

      expect(result).toBe(false);
    });

    it('should return true when baseIds is undefined', async () => {
      const baseId = 'bsexxxxxxxxx';
      const spaceIds = ['spcxxxxxxx'];
      const baseIds = undefined;

      vi.spyOn(service as any, 'getUpperIdByBaseId').mockResolvedValueOnce({
        spaceId: 'spcxxxxxxx',
      });

      const result = await service['isBaseIdAllowedForResource'](baseId, spaceIds, baseIds);

      expect(result).toBe(true);
    });
  });

  describe('isTableIdAllowedForResource', () => {
    it('should return true when tableId is allowed for the resource', async () => {
      const tableId = 'validTableId';
      const spaceIds = ['spcxxxxxx'];
      const baseIds = ['bsexxxxxx'];

      vi.spyOn(service as any, 'getUpperIdByTableId').mockResolvedValueOnce({
        spaceId: 'spcxxxxxx',
        baseId: 'bsexxxxxx',
      });

      const result = await service['isTableIdAllowedForResource'](tableId, spaceIds, baseIds);

      expect(result).toBe(true);
    });

    it('should return false when tableId is not allowed for the resource', async () => {
      const tableId = 'invalidTableId';
      const spaceIds = ['spcxxxxxx'];
      const baseIds = ['bsexxxxxx'];

      vi.spyOn(service as any, 'getUpperIdByTableId').mockResolvedValueOnce({
        spaceId: 'spc11111111',
        baseId: 'bse1111111',
      });

      const result = await service['isTableIdAllowedForResource'](tableId, spaceIds, baseIds);

      expect(result).toBe(false);
    });

    it('should return true when baseIds is undefined', async () => {
      const tableId = 'tblxxxxxx';
      const spaceIds = ['spcxxxxxx'];
      const baseIds = undefined;

      vi.spyOn(service as any, 'getUpperIdByTableId').mockResolvedValueOnce({
        spaceId: 'spcxxxxxx',
        baseId: 'bsexxxxxxx',
      });

      const result = await service['isTableIdAllowedForResource'](tableId, spaceIds, baseIds);

      expect(result).toBe(true);
    });
  });

  describe('getPermissionsByAccessToken', () => {
    it('should return scopes when resourceId is a valid spaceId and allowed', async () => {
      const resourceId = 'spcxxxxxxx';
      const accessTokenId = 'validAccessTokenId';
      const scopes: Action[] = ['table|create', 'table|update'];
      const spaceIds = ['spcxxxxxxx'];

      vi.spyOn(service, 'getAccessToken').mockResolvedValueOnce({
        scopes,
        spaceIds,
        baseIds: undefined,
      });

      const result = await service.getPermissionsByAccessToken(resourceId, accessTokenId);

      expect(result).toEqual(scopes);
    });

    it('should throw ForbiddenException when resourceId is a valid spaceId but not allowed', async () => {
      const resourceId = 'invalidSpaceId';
      const accessTokenId = 'validAccessTokenId';
      const spaceIds = ['spcxxxxxxx'];

      vi.spyOn(service, 'getAccessToken').mockResolvedValueOnce({
        scopes: ['table|update'],
        spaceIds,
        baseIds: undefined,
      });

      await expect(
        service.getPermissionsByAccessToken(resourceId, accessTokenId)
      ).rejects.toThrowError(ForbiddenException);
    });

    it('should throw ForbiddenException when resourceId is a valid baseId but not allowed', async () => {
      const resourceId = 'bsexxxxxx';
      const accessTokenId = 'validAccessTokenId';
      const baseIds = ['bsexxxxxx1'];

      vi.spyOn(service, 'getAccessToken').mockResolvedValueOnce({
        scopes: ['table|read'],
        baseIds,
        spaceIds: undefined,
      });

      vi.spyOn(service as any, 'isBaseIdAllowedForResource').mockResolvedValueOnce(false);

      await expect(
        service.getPermissionsByAccessToken(resourceId, accessTokenId)
      ).rejects.toThrowError(ForbiddenException);
    });

    it('should throw ForbiddenException when resourceId is a valid tableId but not allowed', async () => {
      const resourceId = 'invalidTableId';
      const accessTokenId = 'validAccessTokenId';
      const baseIds = ['bsexxxxxx'];
      const spaceIds = ['spcxxxxxxx'];

      vi.spyOn(service, 'getAccessToken').mockResolvedValueOnce({
        scopes: ['table|read'],
        spaceIds,
        baseIds,
      });

      await expect(
        service.getPermissionsByAccessToken(resourceId, accessTokenId)
      ).rejects.toThrowError(ForbiddenException);
    });
  });

  describe('getPermissions', () => {
    it('should return permissions for a user', async () => {
      const resourceId = 'bsexxxxxx';
      vi.spyOn(service, 'getPermissionsByResourceId').mockResolvedValue(
        getPermissions(Role.Editor)
      );
      const result = await service.getPermissions(resourceId);
      expect(result.includes('view|create')).toEqual(true);
      expect(result.includes('space|create')).toEqual(false);
    });

    it('should return permissions for access token', async () => {
      const resourceId = 'bsexxxxxx';
      vi.spyOn(service, 'getPermissionsByResourceId').mockResolvedValue(
        getPermissions(Role.Editor)
      );
      vi.spyOn(service, 'getPermissionsByAccessToken').mockResolvedValue([
        'view|create',
        'space|delete',
      ]);
      const result = await service.getPermissions(resourceId, 'access-token-id');
      expect(result.includes('view|create')).toEqual(true);
      expect(result.includes('space|delete')).toEqual(false);
      expect(result.includes('view|delete')).toEqual(false);
    });
  });

  describe('validPermissions', () => {
    it('should return true if user has all required permissions', async () => {
      const permissions = getPermissions(Role.Creator);
      vi.spyOn(service, 'getPermissions').mockResolvedValue(permissions);
      const resourceId = 'bsexxxxxx';
      const result = await service.validPermissions(resourceId, ['base|create']);
      expect(result).toEqual(permissions);
    });

    it('should throw an error if user does not have all required permissions', async () => {
      vi.spyOn(service, 'getPermissions').mockResolvedValue(getPermissions(Role.Editor));
      const resourceId = 'bsexxxxxx';
      await expect(service.validPermissions(resourceId, ['space|create'])).rejects.toThrowError(
        new ForbiddenException(`not allowed to operate space|create on ${resourceId}`)
      );
    });
  });
});