From 4cedb00e04a422e653623763a7e76ce2158db12b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= <gaston@getunleash.io>
Date: Thu, 22 Jun 2023 16:42:01 +0200
Subject: [PATCH] fix: fetching user root roles include custom ones (#4068)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

## About the changes
`getUserRootRoles` should also consider custom root roles

This introduces test cases that unveiled a dependency between stores
(this happens actually at the DB layer having access-service access
tables from two different stores but skipping the store layer).


https://linear.app/unleash/issue/2-1161/a-user-with-custom-root-role-and-permission-to-create-client-api

---------

Co-authored-by: Nuno Góis <github@nunogois.com>
---
 .../features/access/createAccessService.ts    |  2 +-
 src/lib/services/access-service.test.ts       | 65 ++++++++++++-------
 src/lib/services/access-service.ts            | 12 ++--
 src/lib/types/model.ts                        |  8 +--
 src/test/fixtures/fake-access-store.ts        | 35 +++++++---
 src/test/fixtures/fake-role-store.ts          | 17 +++--
 6 files changed, 90 insertions(+), 49 deletions(-)

diff --git a/src/lib/features/access/createAccessService.ts b/src/lib/features/access/createAccessService.ts
index cd7b2dee3b..a3c9fca204 100644
--- a/src/lib/features/access/createAccessService.ts
+++ b/src/lib/features/access/createAccessService.ts
@@ -45,7 +45,7 @@ export const createFakeAccessService = (
     const accountStore = new FakeAccountStore();
     const roleStore = new FakeRoleStore();
     const environmentStore = new FakeEnvironmentStore();
-    const accessStore = new FakeAccessStore();
+    const accessStore = new FakeAccessStore(roleStore);
     const groupService = new GroupService(
         { groupStore, eventStore, accountStore },
         { getLogger },
diff --git a/src/lib/services/access-service.test.ts b/src/lib/services/access-service.test.ts
index a9e3385d2c..f78f141a58 100644
--- a/src/lib/services/access-service.test.ts
+++ b/src/lib/services/access-service.test.ts
@@ -1,35 +1,33 @@
 import NameExistsError from '../error/name-exists-error';
 import getLogger from '../../test/fixtures/no-logger';
-import createStores from '../../test/fixtures/store';
-import { AccessService, IRoleValidation } from './access-service';
-import { GroupService } from './group-service';
+import { createFakeAccessService } from '../features/access/createAccessService';
+import { IRoleValidation } from './access-service';
 import { createTestConfig } from '../../test/config/test-config';
+import { CUSTOM_ROOT_ROLE_TYPE } from '../util/constants';
 
-function getSetup(withNameInUse: boolean) {
-    const stores = createStores();
-
+function getSetup(customRootRoles: boolean = false) {
     const config = createTestConfig({
         getLogger,
+        experimental: {
+            flags: {
+                customRootRoles: customRootRoles,
+            },
+        },
     });
 
-    stores.roleStore = {
-        ...stores.roleStore,
-        async nameInUse(): Promise<boolean> {
-            return withNameInUse;
-        },
-    };
     return {
-        accessService: new AccessService(stores, config, {} as GroupService),
-        stores,
+        accessService: createFakeAccessService(config),
     };
 }
-test('should fail when name exists', async () => {
-    const { accessService } = getSetup(true);
 
-    const existingRole: IRoleValidation = {
+test('should fail when name exists', async () => {
+    const { accessService } = getSetup();
+    const existingRole = await accessService.createRole({
         name: 'existing role',
         description: 'description',
-    };
+        permissions: [],
+    });
+
     expect(accessService.validateRole(existingRole)).rejects.toThrow(
         new NameExistsError(
             `There already exists a role with the name ${existingRole.name}`,
@@ -38,7 +36,7 @@ test('should fail when name exists', async () => {
 });
 
 test('should validate a role without permissions', async () => {
-    const { accessService } = getSetup(false);
+    const { accessService } = getSetup();
 
     const withoutPermissions: IRoleValidation = {
         name: 'name of the role',
@@ -50,7 +48,7 @@ test('should validate a role without permissions', async () => {
 });
 
 test('should complete description field when not present', async () => {
-    const { accessService } = getSetup(false);
+    const { accessService } = getSetup();
     const withoutDescription: IRoleValidation = {
         name: 'name of the role',
     };
@@ -61,7 +59,7 @@ test('should complete description field when not present', async () => {
 });
 
 test('should accept empty permissions', async () => {
-    const { accessService } = getSetup(false);
+    const { accessService } = getSetup();
     const withEmptyPermissions: IRoleValidation = {
         name: 'name of the role',
         description: 'description',
@@ -75,7 +73,7 @@ test('should accept empty permissions', async () => {
 });
 
 test('should complete environment field of permissions when not present', async () => {
-    const { accessService } = getSetup(false);
+    const { accessService } = getSetup();
     const withoutEnvironmentInPermissions: IRoleValidation = {
         name: 'name of the role',
         description: 'description',
@@ -100,7 +98,7 @@ test('should complete environment field of permissions when not present', async
 });
 
 test('should return the same object when all fields are valid and present', async () => {
-    const { accessService } = getSetup(false);
+    const { accessService } = getSetup();
 
     const roleWithAllFields: IRoleValidation = {
         name: 'name of the role',
@@ -125,7 +123,7 @@ test('should return the same object when all fields are valid and present', asyn
 });
 
 test('should be able to validate and cleanup with additional properties', async () => {
-    const { accessService } = getSetup(false);
+    const { accessService } = getSetup();
     const base = {
         name: 'name of the role',
         description: 'description',
@@ -152,3 +150,22 @@ test('should be able to validate and cleanup with additional properties', async
         ],
     });
 });
+
+test('user with custom root role should get a user root role', async () => {
+    const { accessService } = getSetup(true);
+    const customRootRole = await accessService.createRole({
+        name: 'custom-root-role',
+        description: 'test custom root role',
+        type: CUSTOM_ROOT_ROLE_TYPE,
+        permissions: [],
+    });
+    const user = {
+        id: 1,
+        rootRole: customRootRole.id,
+    };
+    await accessService.setUserRootRole(user.id, customRootRole.id);
+
+    const roles = await accessService.getUserRootRoles(user.id);
+    expect(roles).toHaveLength(1);
+    expect(roles[0].name).toBe('custom-root-role');
+});
diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts
index 8b88c56905..bd2841de4f 100644
--- a/src/lib/services/access-service.ts
+++ b/src/lib/services/access-service.ts
@@ -18,7 +18,6 @@ import {
     IRoleData,
     IUserWithRole,
     RoleName,
-    RoleType,
 } from '../types/model';
 import { IRoleStore } from 'lib/types/stores/role-store';
 import NameExistsError from '../error/name-exists-error';
@@ -30,6 +29,7 @@ import {
     ALL_PROJECTS,
     CUSTOM_ROOT_ROLE_TYPE,
     CUSTOM_PROJECT_ROLE_TYPE,
+    ROOT_ROLE_TYPES,
 } from '../util/constants';
 import { DEFAULT_PROJECT } from '../types/project';
 import InvalidOperationError from '../error/invalid-operation-error';
@@ -253,10 +253,10 @@ export class AccessService {
         const newRootRole = await this.resolveRootRole(role);
         if (newRootRole) {
             try {
-                await this.store.removeRolesOfTypeForUser(userId, [
-                    RoleType.ROOT,
-                    RoleType.ROOT_CUSTOM,
-                ]);
+                await this.store.removeRolesOfTypeForUser(
+                    userId,
+                    ROOT_ROLE_TYPES,
+                );
 
                 await this.store.addUserToRole(
                     userId,
@@ -275,7 +275,7 @@ export class AccessService {
 
     async getUserRootRoles(userId: number): Promise<IRoleWithProject[]> {
         const userRoles = await this.store.getRolesForUserId(userId);
-        return userRoles.filter((r) => r.type === RoleType.ROOT);
+        return userRoles.filter(({ type }) => ROOT_ROLE_TYPES.includes(type));
     }
 
     async removeUserFromRole(
diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts
index be8d8d59f4..f472852d18 100644
--- a/src/lib/types/model.ts
+++ b/src/lib/types/model.ts
@@ -386,11 +386,11 @@ export interface IProject {
     defaultStickiness: string;
 }
 
-export interface ICustomRole {
-    id: number;
-    name: string;
+/**
+ * Extends IRole making description mandatory
+ */
+export interface ICustomRole extends IRole {
     description: string;
-    type: string;
 }
 
 export interface IProjectWithCount extends IProject {
diff --git a/src/test/fixtures/fake-access-store.ts b/src/test/fixtures/fake-access-store.ts
index c4220f00b4..c754246280 100644
--- a/src/test/fixtures/fake-access-store.ts
+++ b/src/test/fixtures/fake-access-store.ts
@@ -8,8 +8,18 @@ import {
     IUserRole,
 } from '../../lib/types/stores/access-store';
 import { IPermission } from 'lib/types/model';
+import { IRoleStore } from 'lib/types';
+import FakeRoleStore from './fake-role-store';
 
 class AccessStoreMock implements IAccessStore {
+    fakeRolesStore: IRoleStore;
+
+    userToRoleMap: Map<number, number> = new Map();
+
+    constructor(roleStore?: IRoleStore) {
+        this.fakeRolesStore = roleStore ?? new FakeRoleStore();
+    }
+
     addAccessToProject(
         users: IAccessInfo[],
         groups: IAccessInfo[],
@@ -88,13 +98,9 @@ class AccessStoreMock implements IAccessStore {
         role_id: number,
         permissions: IPermission[],
     ): Promise<void> {
-        throw new Error('Method not implemented.');
+        return Promise.resolve(undefined);
     }
 
-    userPermissions: IUserPermission[] = [];
-
-    roles: IRole[] = [];
-
     getAvailablePermissions(): Promise<IPermission[]> {
         throw new Error('Method not implemented.');
     }
@@ -123,8 +129,17 @@ class AccessStoreMock implements IAccessStore {
         throw new Error('Method not implemented.');
     }
 
-    getRolesForUserId(userId: number): Promise<IRoleWithProject[]> {
-        return Promise.resolve([]);
+    async getRolesForUserId(userId: number): Promise<IRoleWithProject[]> {
+        const roleId = this.userToRoleMap.get(userId);
+        const found =
+            roleId === undefined
+                ? undefined
+                : await this.fakeRolesStore.get(roleId);
+        if (found) {
+            return Promise.resolve([found as IRoleWithProject]);
+        } else {
+            return Promise.resolve([]);
+        }
     }
 
     getUserIdsForRole(roleId: number, projectId: string): Promise<number[]> {
@@ -132,7 +147,8 @@ class AccessStoreMock implements IAccessStore {
     }
 
     addUserToRole(userId: number, roleId: number): Promise<void> {
-        throw new Error('Method not implemented.');
+        this.userToRoleMap.set(userId, roleId);
+        return Promise.resolve(undefined);
     }
 
     addPermissionsToRole(
@@ -140,7 +156,8 @@ class AccessStoreMock implements IAccessStore {
         permissions: string[],
         projectId?: string,
     ): Promise<void> {
-        throw new Error('Method not implemented.');
+        // do nothing for now
+        return Promise.resolve(undefined);
     }
 
     removePermissionFromRole(
diff --git a/src/test/fixtures/fake-role-store.ts b/src/test/fixtures/fake-role-store.ts
index 6c7053ef64..b4888c8cd4 100644
--- a/src/test/fixtures/fake-role-store.ts
+++ b/src/test/fixtures/fake-role-store.ts
@@ -19,7 +19,9 @@ export default class FakeRoleStore implements IRoleStore {
     }
 
     nameInUse(name: string, existingId?: number): Promise<boolean> {
-        throw new Error('Method not implemented.');
+        return Promise.resolve(
+            this.roles.find((r) => r.name === name) !== undefined,
+        );
     }
 
     async getAll(): Promise<ICustomRole[]> {
@@ -29,7 +31,7 @@ export default class FakeRoleStore implements IRoleStore {
     async create(role: ICustomRoleInsert): Promise<ICustomRole> {
         const roleCreated = {
             ...role,
-            type: 'some-type',
+            type: role.roleType,
             id: this.roles.length,
         };
         this.roles.push(roleCreated);
@@ -49,7 +51,7 @@ export default class FakeRoleStore implements IRoleStore {
     }
 
     async getRoleByName(name: string): Promise<IRole> {
-        return this.roles.find((r) => (r.name = name));
+        return this.roles.find((r) => (r.name = name)) as IRole;
     }
 
     getRolesForProject(projectId: string): Promise<IRole[]> {
@@ -72,8 +74,13 @@ export default class FakeRoleStore implements IRoleStore {
         throw new Error('Method not implemented.');
     }
 
-    get(key: number): Promise<ICustomRole> {
-        throw new Error('Method not implemented.');
+    get(id: number): Promise<ICustomRole> {
+        const found = this.roles.find((r) => r.id === id);
+        if (!found) {
+            // this edge case is not properly contemplated in the type definition
+            throw new Error('Not found');
+        }
+        return Promise.resolve(found);
     }
 
     exists(key: number): Promise<boolean> {