From fcec9cdb0f236e7073a867088bef31ebbc6bde33 Mon Sep 17 00:00:00 2001 From: haimingzz <535257617@qq.com> Date: Wed, 29 Apr 2026 20:36:48 +0800 Subject: [PATCH] fix(core): retry role mapping order races --- .../role-mapping-rule.service.ee.test.ts | 875 +----------------- .../role-mapping-rule.service.ee.ts | 342 +------ 2 files changed, 2 insertions(+), 1215 deletions(-) diff --git a/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.service.ee.test.ts b/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.service.ee.test.ts index 9c67bcca73d5c..c3f7c7948bcee 100644 --- a/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.service.ee.test.ts +++ b/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.service.ee.test.ts @@ -1,874 +1 @@ -import { mock } from 'jest-mock-extended'; - -import { RoleMappingRuleService } from '@/modules/provisioning.ee/role-mapping-rule.service.ee'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { - Project, - ProjectRepository, - Role, - RoleMappingRule, - RoleMappingRuleRepository, - RoleRepository, -} from '@n8n/db'; - -const roleMappingRuleRepository = mock(); -const roleRepository = mock(); -const projectRepository = mock(); - -const service = new RoleMappingRuleService( - roleMappingRuleRepository, - roleRepository, - projectRepository, -); - -const globalRole: Role = { - slug: 'global:member', - displayName: 'Member', - description: null, - systemRole: true, - roleType: 'global', - projectRelations: [], - roleMappingRules: [], - scopes: [], - createdAt: new Date(), - updatedAt: new Date(), - setUpdateDate: () => {}, -}; - -const projectRole: Role = { - slug: 'project:editor', - displayName: 'Editor', - description: null, - systemRole: true, - roleType: 'project', - projectRelations: [], - roleMappingRules: [], - scopes: [], - createdAt: new Date(), - updatedAt: new Date(), - setUpdateDate: () => {}, -}; - -describe('RoleMappingRuleService', () => { - const defaultUpdateSpy = jest.fn().mockResolvedValue(undefined); - const defaultTransactionSpy = jest - .fn() - .mockImplementation(async (cb: (tx: { update: typeof defaultUpdateSpy }) => Promise) => { - await cb({ update: defaultUpdateSpy }); - }); - - beforeEach(() => { - jest.clearAllMocks(); - roleMappingRuleRepository.findOne.mockResolvedValue(null); - // normalizeOrderForType calls find after every mutation; default to empty - // so existing tests hit the early-exit path and require no transaction mock. - roleMappingRuleRepository.find.mockResolvedValue([]); - // create() always calls applyOrder (which uses a transaction) to splice the - // newly-saved rule into the existing order. Inner describes may override. - (roleMappingRuleRepository as unknown as Record).manager = { - transaction: defaultTransactionSpy, - }; - defaultUpdateSpy.mockClear(); - defaultTransactionSpy.mockClear(); - }); - - describe('create', () => { - it('should reject project type without projectIds', async () => { - await expect( - service.create({ - expression: 'true', - role: 'project:editor', - type: 'project', - order: 0, - }), - ).rejects.toThrow(BadRequestError); - - await expect( - service.create({ - expression: 'true', - role: 'project:editor', - type: 'project', - order: 0, - projectIds: [], - }), - ).rejects.toThrow(BadRequestError); - }); - - it('should reject instance type with non-empty projectIds', async () => { - await expect( - service.create({ - expression: 'true', - role: 'global:member', - type: 'instance', - order: 0, - projectIds: ['proj-1'], - }), - ).rejects.toThrow(BadRequestError); - }); - - it('should reject when role slug is unknown', async () => { - roleRepository.findOne.mockResolvedValue(null); - - await expect( - service.create({ - expression: 'true', - role: 'global:missing', - type: 'instance', - order: 0, - }), - ).rejects.toThrow(NotFoundError); - }); - - it('should reject instance rule with a non-global role', async () => { - roleRepository.findOne.mockResolvedValue(projectRole); - - await expect( - service.create({ - expression: 'true', - role: 'project:editor', - type: 'instance', - order: 0, - }), - ).rejects.toThrow(BadRequestError); - }); - - it('should reject project rule with a non-project role', async () => { - roleRepository.findOne.mockResolvedValue(globalRole); - - await expect( - service.create({ - expression: 'true', - role: 'global:member', - type: 'project', - order: 0, - projectIds: ['p1'], - }), - ).rejects.toThrow(BadRequestError); - }); - - it('should reject when some project ids do not exist', async () => { - roleRepository.findOne.mockResolvedValue(projectRole); - projectRepository.findBy.mockResolvedValue([{ id: 'p1' } as Project]); - - await expect( - service.create({ - expression: 'true', - role: 'project:editor', - type: 'project', - order: 0, - projectIds: ['p1', 'p2'], - }), - ).rejects.toThrow(BadRequestError); - }); - - it('should create an instance rule and return a response DTO', async () => { - roleRepository.findOne.mockResolvedValue(globalRole); - - const savedRule = { - id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', - expression: 'claims.group === "admins"', - role: globalRole, - type: 'instance', - order: 2, - projects: [], - createdAt: new Date('2025-01-01T00:00:00.000Z'), - updatedAt: new Date('2025-01-01T00:00:00.000Z'), - } as unknown as RoleMappingRule; - - roleMappingRuleRepository.save.mockImplementation(async (r) => { - expect(r).toBeInstanceOf(Object); - return { ...savedRule } as unknown as RoleMappingRule; - }); - - const loadedRule = { - ...savedRule, - role: globalRole, - projects: [], - } as unknown as RoleMappingRule; - - roleMappingRuleRepository.findOneOrFail.mockResolvedValue(loadedRule); - - const result = await service.create({ - expression: savedRule.expression, - role: globalRole.slug, - type: 'instance', - order: 2, - }); - - expect(result).toEqual({ - id: savedRule.id, - expression: savedRule.expression, - role: globalRole.slug, - type: 'instance', - order: 2, - projectIds: [], - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', - }); - - expect(roleMappingRuleRepository.save).toHaveBeenCalledTimes(1); - expect(projectRepository.findBy).not.toHaveBeenCalled(); - }); - - it('should create a project rule linked to projects', async () => { - roleRepository.findOne.mockResolvedValue(projectRole); - - const projA = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' } as Project; - const projB = { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' } as Project; - projectRepository.findBy.mockResolvedValue([projA, projB]); - - const savedRule = { - id: 'c0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22', - expression: 'true', - role: projectRole, - type: 'project', - order: 1, - projects: [projA, projB], - createdAt: new Date('2025-02-01T12:00:00.000Z'), - updatedAt: new Date('2025-02-01T12:00:00.000Z'), - } as unknown as RoleMappingRule; - - roleMappingRuleRepository.save.mockResolvedValue({ - ...savedRule, - } as unknown as RoleMappingRule); - roleMappingRuleRepository.findOneOrFail.mockResolvedValue(savedRule); - - const projectIds = [projA.id, projB.id]; - const result = await service.create({ - expression: 'true', - role: projectRole.slug, - type: 'project', - order: 1, - projectIds, - }); - - expect(result.projectIds).toEqual(expect.arrayContaining(projectIds)); - expect(result.projectIds).toHaveLength(2); - expect(result.type).toBe('project'); - expect(projectRepository.findBy).toHaveBeenCalled(); - }); - - it('should dedupe duplicate projectIds before loading projects', async () => { - roleRepository.findOne.mockResolvedValue(projectRole); - - const projA = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' } as Project; - projectRepository.findBy.mockResolvedValue([projA]); - - const savedRule = { - id: 'd0eebc99-9c0b-4ef8-bb6d-6bb9bd380a33', - expression: 'true', - role: projectRole, - type: 'project', - order: 1, - projects: [projA], - createdAt: new Date('2025-02-01T12:00:00.000Z'), - updatedAt: new Date('2025-02-01T12:00:00.000Z'), - } as unknown as RoleMappingRule; - - roleMappingRuleRepository.save.mockResolvedValue({ - ...savedRule, - } as unknown as RoleMappingRule); - roleMappingRuleRepository.findOneOrFail.mockResolvedValue(savedRule); - - const result = await service.create({ - expression: 'true', - role: projectRole.slug, - type: 'project', - order: 1, - projectIds: [projA.id, projA.id], - }); - - expect(result.projectIds).toEqual([projA.id]); - expect(projectRepository.findBy).toHaveBeenCalledTimes(1); - }); - - it('should append when order is omitted', async () => { - roleRepository.findOne.mockResolvedValue(globalRole); - roleMappingRuleRepository.find.mockResolvedValue([ - { id: 'rule-a', order: 0 } as RoleMappingRule, - { id: 'rule-b', order: 1 } as RoleMappingRule, - ]); - - const savedRule = { - id: 'rule-c', - expression: 'claims.c', - role: globalRole, - type: 'instance', - order: 2, - projects: [], - createdAt: new Date('2025-01-01T00:00:00.000Z'), - updatedAt: new Date('2025-01-01T00:00:00.000Z'), - } as unknown as RoleMappingRule; - - roleMappingRuleRepository.save.mockImplementation(async (r) => ({ - ...(r as RoleMappingRule), - id: 'rule-c', - })); - roleMappingRuleRepository.findOneOrFail.mockResolvedValue(savedRule); - - await service.create({ - expression: 'claims.c', - role: globalRole.slug, - type: 'instance', - }); - - // applyOrder should renumber [rule-a, rule-b, rule-c] to [0, 1, 2] - expect(defaultUpdateSpy).toHaveBeenCalledWith( - expect.anything(), - { id: 'rule-a' }, - { order: 0 }, - ); - expect(defaultUpdateSpy).toHaveBeenCalledWith( - expect.anything(), - { id: 'rule-b' }, - { order: 1 }, - ); - expect(defaultUpdateSpy).toHaveBeenCalledWith( - expect.anything(), - { id: 'rule-c' }, - { order: 2 }, - ); - }); - - it('should insert at index 0 and shift existing rules down', async () => { - roleRepository.findOne.mockResolvedValue(globalRole); - roleMappingRuleRepository.find.mockResolvedValue([ - { id: 'rule-a', order: 0 } as RoleMappingRule, - { id: 'rule-b', order: 1 } as RoleMappingRule, - ]); - - const savedRule = { - id: 'rule-new', - expression: 'claims.new', - role: globalRole, - type: 'instance', - order: 0, - projects: [], - createdAt: new Date('2025-01-01T00:00:00.000Z'), - updatedAt: new Date('2025-01-01T00:00:00.000Z'), - } as unknown as RoleMappingRule; - - roleMappingRuleRepository.save.mockImplementation(async (r) => ({ - ...(r as RoleMappingRule), - id: 'rule-new', - })); - roleMappingRuleRepository.findOneOrFail.mockResolvedValue(savedRule); - - await service.create({ - expression: 'claims.new', - role: globalRole.slug, - type: 'instance', - order: 0, - }); - - // applyOrder should renumber [rule-new, rule-a, rule-b] to [0, 1, 2] - expect(defaultUpdateSpy).toHaveBeenCalledWith( - expect.anything(), - { id: 'rule-new' }, - { order: 0 }, - ); - expect(defaultUpdateSpy).toHaveBeenCalledWith( - expect.anything(), - { id: 'rule-a' }, - { order: 1 }, - ); - expect(defaultUpdateSpy).toHaveBeenCalledWith( - expect.anything(), - { id: 'rule-b' }, - { order: 2 }, - ); - }); - - it('should clamp supplied order beyond existing list length to the end', async () => { - roleRepository.findOne.mockResolvedValue(globalRole); - roleMappingRuleRepository.find.mockResolvedValue([ - { id: 'rule-a', order: 0 } as RoleMappingRule, - ]); - - const savedRule = { - id: 'rule-new', - expression: 'claims.new', - role: globalRole, - type: 'instance', - order: 1, - projects: [], - createdAt: new Date('2025-01-01T00:00:00.000Z'), - updatedAt: new Date('2025-01-01T00:00:00.000Z'), - } as unknown as RoleMappingRule; - - roleMappingRuleRepository.save.mockImplementation(async (r) => ({ - ...(r as RoleMappingRule), - id: 'rule-new', - })); - roleMappingRuleRepository.findOneOrFail.mockResolvedValue(savedRule); - - await service.create({ - expression: 'claims.new', - role: globalRole.slug, - type: 'instance', - order: 999, - }); - - // Clamped to end: [rule-a, rule-new] → orders [0, 1] - expect(defaultUpdateSpy).toHaveBeenCalledWith( - expect.anything(), - { id: 'rule-a' }, - { order: 0 }, - ); - expect(defaultUpdateSpy).toHaveBeenCalledWith( - expect.anything(), - { id: 'rule-new' }, - { order: 1 }, - ); - }); - }); - - describe('list', () => { - it('should return paginated rules with default order sort', async () => { - const ruleA = { - id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', - expression: 'a', - role: globalRole, - type: 'instance', - order: 0, - projects: [], - createdAt: new Date('2025-01-01T00:00:00.000Z'), - updatedAt: new Date('2025-01-01T00:00:00.000Z'), - } as unknown as RoleMappingRule; - - roleMappingRuleRepository.findAndCount.mockResolvedValue([[ruleA], 1]); - - const result = await service.list({ skip: 0, take: 10 }); - - expect(result.count).toBe(1); - expect(result.items).toHaveLength(1); - expect(result.items[0]).toMatchObject({ - id: ruleA.id, - expression: 'a', - order: 0, - type: 'instance', - }); - - expect(roleMappingRuleRepository.findAndCount).toHaveBeenCalledWith({ - where: {}, - relations: ['projects', 'role'], - order: { order: 'ASC', id: 'ASC' }, - skip: 0, - take: 10, - }); - }); - - it('should filter by type and apply sortBy', async () => { - roleMappingRuleRepository.findAndCount.mockResolvedValue([[], 0]); - - await service.list({ - skip: 0, - take: 5, - type: 'project', - sortBy: 'createdAt:desc', - }); - - expect(roleMappingRuleRepository.findAndCount).toHaveBeenCalledWith({ - where: { type: 'project' }, - relations: ['projects', 'role'], - order: { createdAt: 'DESC', id: 'ASC' }, - skip: 0, - take: 5, - }); - }); - }); - - describe('patch', () => { - const existingInstanceRule = { - id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', - expression: 'claims.group === "admins"', - role: globalRole, - type: 'instance', - order: 0, - projects: [], - createdAt: new Date('2025-01-01T00:00:00.000Z'), - updatedAt: new Date('2025-01-01T00:00:00.000Z'), - } as unknown as RoleMappingRule; - - beforeEach(() => { - roleMappingRuleRepository.findOne.mockImplementation(async (options) => { - if ( - options?.where && - 'id' in options.where && - options.where.id === existingInstanceRule.id - ) { - return { - ...existingInstanceRule, - role: globalRole, - projects: [], - } as unknown as RoleMappingRule; - } - return null; - }); - }); - - it('should return 404 when rule id is unknown', async () => { - await expect( - service.patch('00000000-0000-4000-8000-000000000000', { expression: 'true' }), - ).rejects.toThrow(NotFoundError); - }); - - it('should reject an empty patch payload', async () => { - await expect(service.patch(existingInstanceRule.id, {})).rejects.toThrow(BadRequestError); - }); - - it('should update expression and return loaded rule', async () => { - const updatedRule = { - ...existingInstanceRule, - expression: 'claims.new === 1', - role: globalRole, - projects: [], - updatedAt: new Date('2025-06-01T00:00:00.000Z'), - } as unknown as RoleMappingRule; - - roleMappingRuleRepository.save.mockImplementation(async (r) => r as RoleMappingRule); - roleMappingRuleRepository.findOneOrFail.mockResolvedValue(updatedRule); - - const result = await service.patch(existingInstanceRule.id, { - expression: 'claims.new === 1', - }); - - expect(result.expression).toBe('claims.new === 1'); - expect(result.role).toBe(globalRole.slug); - expect(roleMappingRuleRepository.save).toHaveBeenCalledTimes(1); - }); - - it('should return 409 when order collides with another rule', async () => { - const otherId = 'b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22'; - roleMappingRuleRepository.findOne.mockImplementation(async (options) => { - if ( - options?.where && - 'id' in options.where && - options.where.id === existingInstanceRule.id - ) { - return { - ...existingInstanceRule, - role: globalRole, - projects: [], - } as unknown as RoleMappingRule; - } - if ( - options?.where && - 'type' in options.where && - options.where.type === 'instance' && - options.where.order === 5 - ) { - return { id: otherId } as unknown as RoleMappingRule; - } - return null; - }); - - await expect(service.patch(existingInstanceRule.id, { order: 5 })).rejects.toThrow( - ConflictError, - ); - }); - - it('should allow patch that keeps the same type and order', async () => { - roleMappingRuleRepository.findOne.mockImplementation(async (options) => { - if ( - options?.where && - 'id' in options.where && - options.where.id === existingInstanceRule.id - ) { - return { - ...existingInstanceRule, - role: globalRole, - projects: [], - } as unknown as RoleMappingRule; - } - if ( - options?.where && - 'type' in options.where && - 'order' in options.where && - options.where.type === 'instance' && - options.where.order === 0 - ) { - return { ...existingInstanceRule } as unknown as RoleMappingRule; - } - return null; - }); - - const updatedRule = { - ...existingInstanceRule, - expression: 'true', - role: globalRole, - projects: [], - } as unknown as RoleMappingRule; - - roleMappingRuleRepository.save.mockImplementation(async (r) => r as RoleMappingRule); - roleMappingRuleRepository.findOneOrFail.mockResolvedValue(updatedRule); - - await expect( - service.patch(existingInstanceRule.id, { expression: 'true' }), - ).resolves.toMatchObject({ order: 0, type: 'instance' }); - }); - }); - - describe('delete', () => { - const rule = { - id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', - expression: 'true', - role: globalRole, - type: 'instance', - order: 0, - projects: [], - createdAt: new Date('2025-01-01T00:00:00.000Z'), - updatedAt: new Date('2025-01-01T00:00:00.000Z'), - } as unknown as RoleMappingRule; - - it('should reject an empty id', async () => { - await expect(service.delete('')).rejects.toThrow(BadRequestError); - }); - - it('should return 404 when rule id is unknown', async () => { - roleMappingRuleRepository.findOne.mockResolvedValue(null); - - await expect(service.delete('00000000-0000-4000-8000-000000000000')).rejects.toThrow( - NotFoundError, - ); - expect(roleMappingRuleRepository.remove).not.toHaveBeenCalled(); - }); - - it('should remove the rule when it exists', async () => { - roleMappingRuleRepository.findOne.mockResolvedValue(rule); - roleMappingRuleRepository.remove.mockResolvedValue(rule); - - await service.delete(rule.id); - - expect(roleMappingRuleRepository.remove).toHaveBeenCalledWith(rule); - }); - }); - - describe('deleteAllOfType', () => { - it('should delete all rules of the given type using the direct repository when no tx is provided', async () => { - roleMappingRuleRepository.delete.mockResolvedValue({ affected: 3, raw: {} }); - - const count = await service.deleteAllOfType('project'); - - expect(roleMappingRuleRepository.delete).toHaveBeenCalledWith({ type: 'project' }); - expect(count).toBe(3); - }); - - it('should use the transactional repository when an EntityManager is provided', async () => { - const txRepoDelete = jest.fn().mockResolvedValue({ affected: 2, raw: {} }); - const txRepository = { delete: txRepoDelete }; - const getRepository = jest.fn().mockReturnValue(txRepository); - const tx = { getRepository } as unknown as Parameters[1]; - - const count = await service.deleteAllOfType('instance', tx); - - expect(getRepository).toHaveBeenCalled(); - expect(txRepoDelete).toHaveBeenCalledWith({ type: 'instance' }); - expect(count).toBe(2); - expect(roleMappingRuleRepository.delete).not.toHaveBeenCalled(); - }); - - it('should return 0 when no rows match', async () => { - roleMappingRuleRepository.delete.mockResolvedValue({ affected: 0, raw: {} }); - - const count = await service.deleteAllOfType('project'); - - expect(count).toBe(0); - }); - - it('should treat missing affected count as 0', async () => { - roleMappingRuleRepository.delete.mockResolvedValue({ affected: undefined, raw: {} }); - - const count = await service.deleteAllOfType('project'); - - expect(count).toBe(0); - }); - }); - - describe('move', () => { - const updateSpy = jest.fn().mockResolvedValue(undefined); - const transactionSpy = jest.fn().mockImplementation(async (cb) => { - await cb({ update: updateSpy }); - }); - - beforeEach(() => { - (roleMappingRuleRepository as unknown as Record).manager = { - transaction: transactionSpy, - }; - updateSpy.mockClear(); - transactionSpy.mockClear(); - }); - - const makeRule = (id: string, order: number, type = 'instance') => - ({ - id, - order, - type, - role: { slug: 'global:member' }, - projects: [], - }) as unknown as RoleMappingRule; - - it('should throw NotFoundError when rule does not exist', async () => { - roleMappingRuleRepository.findOne.mockResolvedValue(null); - - await expect(service.move('nonexistent', 0)).rejects.toThrow(NotFoundError); - }); - - it('should move first rule to last position', async () => { - const rules = [makeRule('a', 0), makeRule('b', 1), makeRule('c', 2)]; - roleMappingRuleRepository.findOne.mockResolvedValue(rules[0]); - roleMappingRuleRepository.find.mockResolvedValue(rules); - roleMappingRuleRepository.findOneOrFail.mockResolvedValue({ - ...rules[0], - order: 2, - createdAt: new Date(), - updatedAt: new Date(), - } as unknown as RoleMappingRule); - - await service.move('a', 2); - - // Verify applyOrder called with correct sequence: b, c, a - expect(transactionSpy).toHaveBeenCalledTimes(1); - }); - - it('should move last rule to first position', async () => { - const rules = [makeRule('a', 0), makeRule('b', 1), makeRule('c', 2)]; - roleMappingRuleRepository.findOne.mockResolvedValue(rules[2]); - roleMappingRuleRepository.find.mockResolvedValue(rules); - roleMappingRuleRepository.findOneOrFail.mockResolvedValue({ - ...rules[2], - order: 0, - createdAt: new Date(), - updatedAt: new Date(), - } as unknown as RoleMappingRule); - - await service.move('c', 0); - - expect(transactionSpy).toHaveBeenCalledTimes(1); - }); - - it('should clamp targetIndex to last position when out of bounds', async () => { - const rules = [makeRule('a', 0), makeRule('b', 1)]; - roleMappingRuleRepository.findOne.mockResolvedValue(rules[0]); - roleMappingRuleRepository.find.mockResolvedValue(rules); - roleMappingRuleRepository.findOneOrFail.mockResolvedValue({ - ...rules[0], - order: 1, - createdAt: new Date(), - updatedAt: new Date(), - } as unknown as RoleMappingRule); - - // targetIndex 999 should clamp to 1 (last valid index) - await service.move('a', 999); - - expect(transactionSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('normalizeOrderForType', () => { - const makeRule = (id: string, order: number, type = 'instance') => - ({ id, order, type }) as unknown as RoleMappingRule; - - const updateSpy = jest.fn().mockResolvedValue(undefined); - const transactionSpy = jest.fn(); - - beforeEach(() => { - updateSpy.mockClear(); - transactionSpy.mockImplementation( - async (cb: (tx: { update: typeof updateSpy }) => Promise) => { - await cb({ update: updateSpy }); - }, - ); - // jest-mock-extended creates a Proxy; assigning manager directly - // is the reliable way to inject the transaction mock. - (roleMappingRuleRepository as unknown as Record).manager = { - transaction: transactionSpy, - }; - }); - - it('should not call transaction when sequence has no gaps', async () => { - roleMappingRuleRepository.find.mockResolvedValue([ - makeRule('a', 0), - makeRule('b', 1), - makeRule('c', 2), - ]); - - roleMappingRuleRepository.findOne.mockResolvedValue(makeRule('a', 0)); - roleMappingRuleRepository.remove.mockResolvedValue(makeRule('a', 0)); - - await service.delete('a'); - - expect(transactionSpy).not.toHaveBeenCalled(); - }); - - it('should renumber rules to close a gap after delete', async () => { - // Simulates [0, 2, 3] after deleting the rule at order 1 - roleMappingRuleRepository.find.mockResolvedValue([ - makeRule('a', 0), - makeRule('b', 2), - makeRule('c', 3), - ]); - - roleMappingRuleRepository.findOne.mockResolvedValue(makeRule('x', 0)); - roleMappingRuleRepository.remove.mockResolvedValue(makeRule('x', 0)); - - await service.delete('x'); - - expect(transactionSpy).toHaveBeenCalledTimes(1); - - // Phase 2 should assign contiguous orders 0, 1, 2 - expect(updateSpy).toHaveBeenCalledWith(expect.anything(), { id: 'a' }, { order: 0 }); - expect(updateSpy).toHaveBeenCalledWith(expect.anything(), { id: 'b' }, { order: 1 }); - expect(updateSpy).toHaveBeenCalledWith(expect.anything(), { id: 'c' }, { order: 2 }); - }); - - it('should normalize both types when type changes during patch', async () => { - const existingRule = { - id: 'rule-1', - expression: 'true', - role: globalRole, - type: 'instance', - order: 1, - projects: [], - } as unknown as RoleMappingRule; - - roleMappingRuleRepository.findOne.mockImplementation(async (opts) => { - if (opts?.where && 'id' in opts.where) return existingRule; - return null; - }); - roleMappingRuleRepository.save.mockResolvedValue(existingRule); - roleMappingRuleRepository.findOneOrFail.mockResolvedValue({ - ...existingRule, - type: 'project', - role: projectRole, - projects: [{ id: 'p1' } as Project], - createdAt: new Date('2025-01-01T00:00:00.000Z'), - updatedAt: new Date('2025-01-01T00:00:00.000Z'), - } as unknown as RoleMappingRule); - projectRepository.findBy.mockResolvedValue([{ id: 'p1' } as Project]); - roleRepository.findOne.mockResolvedValue(projectRole); - - roleMappingRuleRepository.find - .mockResolvedValueOnce([makeRule('rule-1', 0, 'project')]) // new type: project — no gap - .mockResolvedValueOnce([ - makeRule('rule-2', 0, 'instance'), - makeRule('rule-3', 2, 'instance'), - ]); // old type: instance has gap - - await service.patch('rule-1', { - type: 'project', - role: projectRole.slug, - projectIds: ['p1'], - order: 0, - }); - - // Called twice: once for new type (project), once for old type (instance) - expect(roleMappingRuleRepository.find).toHaveBeenCalledTimes(2); - // Project sequence has no gap — no transaction needed for it - // Instance sequence has gap [0, 2] — transaction called once - expect(transactionSpy).toHaveBeenCalledTimes(1); - }); - }); -}); +"import { mock } from 'jest-mock-extended';\n\nimport { RoleMappingRuleService } from '@/modules/provisioning.ee/role-mapping-rule.service.ee';\nimport { BadRequestError } from '@/errors/response-errors/bad-request.error';\nimport { ConflictError } from '@/errors/response-errors/conflict.error';\nimport { NotFoundError } from '@/errors/response-errors/not-found.error';\nimport type {\n\tProject,\n\tProjectRepository,\n\tRole,\n\tRoleMappingRule,\n\tRoleMappingRuleRepository,\n\tRoleRepository,\n} from '@n8n/db';\nimport { QueryFailedError } from '@n8n/typeorm';\n\nconst roleMappingRuleRepository = mock();\nconst roleRepository = mock();\nconst projectRepository = mock();\n\nconst service = new RoleMappingRuleService(\n\troleMappingRuleRepository,\n\troleRepository,\n\tprojectRepository,\n);\n\nconst globalRole: Role = {\n\tslug: 'global:member',\n\tdisplayName: 'Member',\n\tdescription: null,\n\tsystemRole: true,\n\troleType: 'global',\n\tprojectRelations: [],\n\troleMappingRules: [],\n\tscopes: [],\n\tcreatedAt: new Date(),\n\tupdatedAt: new Date(),\n\tsetUpdateDate: () => {},\n};\n\nconst projectRole: Role = {\n\tslug: 'project:editor',\n\tdisplayName: 'Editor',\n\tdescription: null,\n\tsystemRole: true,\n\troleType: 'project',\n\tprojectRelations: [],\n\troleMappingRules: [],\n\tscopes: [],\n\tcreatedAt: new Date(),\n\tupdatedAt: new Date(),\n\tsetUpdateDate: () => {},\n};\n\ndescribe('RoleMappingRuleService', () => {\n\tconst defaultUpdateSpy = jest.fn().mockResolvedValue(undefined);\n\tconst defaultTransactionSpy = jest.fn().mockImplementation(\n\t\tasync (\n\t\t\tcb: (tx: { getRepository: typeof defaultGetRepositorySpy; update: typeof defaultUpdateSpy }) =>\n\t\t\t\tPromise,\n\t\t) => {\n\t\t\tawait cb({ getRepository: defaultGetRepositorySpy, update: defaultUpdateSpy });\n\t\t},\n\t);\n\tconst defaultGetRepositorySpy = jest.fn().mockReturnValue(roleMappingRuleRepository);\n\n\tbeforeEach(() => {\n\t\tjest.clearAllMocks();\n\t\troleMappingRuleRepository.findOne.mockResolvedValue(null);\n\t\t// normalizeOrderForType calls find after every mutation; default to empty\n\t\t// so existing tests hit the early-exit path and require no transaction mock.\n\t\troleMappingRuleRepository.find.mockResolvedValue([]);\n\t\t// create() always calls applyOrder (which uses a transaction) to splice the\n\t\t// newly-saved rule into the existing order. Inner describes may override.\n\t\t(roleMappingRuleRepository as unknown as Record).manager = {\n\t\t\ttransaction: defaultTransactionSpy,\n\t\t};\n\t\tdefaultUpdateSpy.mockClear();\n\t\tdefaultTransactionSpy.mockClear();\n\t\tdefaultGetRepositorySpy.mockClear();\n\t\tdefaultGetRepositorySpy.mockReturnValue(roleMappingRuleRepository);\n\t});\n\n\tdescribe('create', () => {\n\t\tit('should reject project type without projectIds', async () => {\n\t\t\tawait expect(\n\t\t\t\tservice.create({\n\t\t\t\t\texpression: 'true',\n\t\t\t\t\trole: 'project:editor',\n\t\t\t\t\ttype: 'project',\n\t\t\t\t\torder: 0,\n\t\t\t\t}),\n\t\t\t).rejects.toThrow(BadRequestError);\n\n\t\t\tawait expect(\n\t\t\t\tservice.create({\n\t\t\t\t\texpression: 'true',\n\t\t\t\t\trole: 'project:editor',\n\t\t\t\t\ttype: 'project',\n\t\t\t\t\torder: 0,\n\t\t\t\t\tprojectIds: [],\n\t\t\t\t}),\n\t\t\t).rejects.toThrow(BadRequestError);\n\t\t});\n\n\t\tit('should reject instance type with non-empty projectIds', async () => {\n\t\t\tawait expect(\n\t\t\t\tservice.create({\n\t\t\t\t\texpression: 'true',\n\t\t\t\t\trole: 'global:member',\n\t\t\t\t\ttype: 'instance',\n\t\t\t\t\torder: 0,\n\t\t\t\t\tprojectIds: ['proj-1'],\n\t\t\t\t}),\n\t\t\t).rejects.toThrow(BadRequestError);\n\t\t});\n\n\t\tit('should reject when role slug is unknown', async () => {\n\t\t\troleRepository.findOne.mockResolvedValue(null);\n\n\t\t\tawait expect(\n\t\t\t\tservice.create({\n\t\t\t\t\texpression: 'true',\n\t\t\t\t\trole: 'global:missing',\n\t\t\t\t\ttype: 'instance',\n\t\t\t\t\torder: 0,\n\t\t\t\t}),\n\t\t\t).rejects.toThrow(NotFoundError);\n\t\t});\n\n\t\tit('should reject instance rule with a non-global role', async () => {\n\t\t\troleRepository.findOne.mockResolvedValue(projectRole);\n\n\t\t\tawait expect(\n\t\t\t\tservice.create({\n\t\t\t\t\texpression: 'true',\n\t\t\t\t\trole: 'project:editor',\n\t\t\t\t\ttype: 'instance',\n\t\t\t\t\torder: 0,\n\t\t\t\t}),\n\t\t\t).rejects.toThrow(BadRequestError);\n\t\t});\n\n\t\tit('should reject project rule with a non-project role', async () => {\n\t\t\troleRepository.findOne.mockResolvedValue(globalRole);\n\n\t\t\tawait expect(\n\t\t\t\tservice.create({\n\t\t\t\t\texpression: 'true',\n\t\t\t\t\trole: 'global:member',\n\t\t\t\t\ttype: 'project',\n\t\t\t\t\torder: 0,\n\t\t\t\t\tprojectIds: ['p1'],\n\t\t\t\t}),\n\t\t\t).rejects.toThrow(BadRequestError);\n\t\t});\n\n\t\tit('should reject when some project ids do not exist', async () => {\n\t\t\troleRepository.findOne.mockResolvedValue(projectRole);\n\t\t\tprojectRepository.findBy.mockResolvedValue([{ id: 'p1' } as Project]);\n\n\t\t\tawait expect(\n\t\t\t\tservice.create({\n\t\t\t\t\texpression: 'true',\n\t\t\t\t\trole: 'project:editor',\n\t\t\t\t\ttype: 'project',\n\t\t\t\t\torder: 0,\n\t\t\t\t\tprojectIds: ['p1', 'p2'],\n\t\t\t\t}),\n\t\t\t).rejects.toThrow(BadRequestError);\n\t\t});\n\n\t\tit('should create an instance rule and return a response DTO', async () => {\n\t\t\troleRepository.findOne.mockResolvedValue(globalRole);\n\n\t\t\tconst savedRule = {\n\t\t\t\tid: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',\n\t\t\t\texpression: 'claims.group === \"admins\"',\n\t\t\t\trole: globalRole,\n\t\t\t\ttype: 'instance',\n\t\t\t\torder: 2,\n\t\t\t\tprojects: [],\n\t\t\t\tcreatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t\tupdatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t} as unknown as RoleMappingRule;\n\n\t\t\troleMappingRuleRepository.save.mockImplementation(async (r) => {\n\t\t\t\texpect(r).toBeInstanceOf(Object);\n\t\t\t\treturn { ...savedRule } as unknown as RoleMappingRule;\n\t\t\t});\n\n\t\t\tconst loadedRule = {\n\t\t\t\t...savedRule,\n\t\t\t\trole: globalRole,\n\t\t\t\tprojects: [],\n\t\t\t} as unknown as RoleMappingRule;\n\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue(loadedRule);\n\n\t\t\tconst result = await service.create({\n\t\t\t\texpression: savedRule.expression,\n\t\t\t\trole: globalRole.slug,\n\t\t\t\ttype: 'instance',\n\t\t\t\torder: 2,\n\t\t\t});\n\n\t\t\texpect(result).toEqual({\n\t\t\t\tid: savedRule.id,\n\t\t\t\texpression: savedRule.expression,\n\t\t\t\trole: globalRole.slug,\n\t\t\t\ttype: 'instance',\n\t\t\t\torder: 2,\n\t\t\t\tprojectIds: [],\n\t\t\t\tcreatedAt: '2025-01-01T00:00:00.000Z',\n\t\t\t\tupdatedAt: '2025-01-01T00:00:00.000Z',\n\t\t\t});\n\n\t\t\texpect(roleMappingRuleRepository.save).toHaveBeenCalledTimes(1);\n\t\t\texpect(projectRepository.findBy).not.toHaveBeenCalled();\n\t\t});\n\n\t\tit('should create a project rule linked to projects', async () => {\n\t\t\troleRepository.findOne.mockResolvedValue(projectRole);\n\n\t\t\tconst projA = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' } as Project;\n\t\t\tconst projB = { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' } as Project;\n\t\t\tprojectRepository.findBy.mockResolvedValue([projA, projB]);\n\n\t\t\tconst savedRule = {\n\t\t\t\tid: 'c0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22',\n\t\t\t\texpression: 'true',\n\t\t\t\trole: projectRole,\n\t\t\t\ttype: 'project',\n\t\t\t\torder: 1,\n\t\t\t\tprojects: [projA, projB],\n\t\t\t\tcreatedAt: new Date('2025-02-01T12:00:00.000Z'),\n\t\t\t\tupdatedAt: new Date('2025-02-01T12:00:00.000Z'),\n\t\t\t} as unknown as RoleMappingRule;\n\n\t\t\troleMappingRuleRepository.save.mockResolvedValue({\n\t\t\t\t...savedRule,\n\t\t\t} as unknown as RoleMappingRule);\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue(savedRule);\n\n\t\t\tconst projectIds = [projA.id, projB.id];\n\t\t\tconst result = await service.create({\n\t\t\t\texpression: 'true',\n\t\t\t\trole: projectRole.slug,\n\t\t\t\ttype: 'project',\n\t\t\t\torder: 1,\n\t\t\t\tprojectIds,\n\t\t\t});\n\n\t\t\texpect(result.projectIds).toEqual(expect.arrayContaining(projectIds));\n\t\t\texpect(result.projectIds).toHaveLength(2);\n\t\t\texpect(result.type).toBe('project');\n\t\t\texpect(projectRepository.findBy).toHaveBeenCalled();\n\t\t});\n\n\t\tit('should dedupe duplicate projectIds before loading projects', async () => {\n\t\t\troleRepository.findOne.mockResolvedValue(projectRole);\n\n\t\t\tconst projA = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' } as Project;\n\t\t\tprojectRepository.findBy.mockResolvedValue([projA]);\n\n\t\t\tconst savedRule = {\n\t\t\t\tid: 'd0eebc99-9c0b-4ef8-bb6d-6bb9bd380a33',\n\t\t\t\texpression: 'true',\n\t\t\t\trole: projectRole,\n\t\t\t\ttype: 'project',\n\t\t\t\torder: 1,\n\t\t\t\tprojects: [projA],\n\t\t\t\tcreatedAt: new Date('2025-02-01T12:00:00.000Z'),\n\t\t\t\tupdatedAt: new Date('2025-02-01T12:00:00.000Z'),\n\t\t\t} as unknown as RoleMappingRule;\n\n\t\t\troleMappingRuleRepository.save.mockResolvedValue({\n\t\t\t\t...savedRule,\n\t\t\t} as unknown as RoleMappingRule);\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue(savedRule);\n\n\t\t\tconst result = await service.create({\n\t\t\t\texpression: 'true',\n\t\t\t\trole: projectRole.slug,\n\t\t\t\ttype: 'project',\n\t\t\t\torder: 1,\n\t\t\t\tprojectIds: [projA.id, projA.id],\n\t\t\t});\n\n\t\t\texpect(result.projectIds).toEqual([projA.id]);\n\t\t\texpect(projectRepository.findBy).toHaveBeenCalledTimes(1);\n\t\t});\n\n\t\tit('should append when order is omitted', async () => {\n\t\t\troleRepository.findOne.mockResolvedValue(globalRole);\n\t\t\troleMappingRuleRepository.find.mockResolvedValue([\n\t\t\t\t{ id: 'rule-a', order: 0 } as RoleMappingRule,\n\t\t\t\t{ id: 'rule-b', order: 1 } as RoleMappingRule,\n\t\t\t]);\n\n\t\t\tconst savedRule = {\n\t\t\t\tid: 'rule-c',\n\t\t\t\texpression: 'claims.c',\n\t\t\t\trole: globalRole,\n\t\t\t\ttype: 'instance',\n\t\t\t\torder: 2,\n\t\t\t\tprojects: [],\n\t\t\t\tcreatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t\tupdatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t} as unknown as RoleMappingRule;\n\n\t\t\troleMappingRuleRepository.save.mockImplementation(async (r) => ({\n\t\t\t\t...(r as RoleMappingRule),\n\t\t\t\tid: 'rule-c',\n\t\t\t}));\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue(savedRule);\n\n\t\t\tawait service.create({\n\t\t\t\texpression: 'claims.c',\n\t\t\t\trole: globalRole.slug,\n\t\t\t\ttype: 'instance',\n\t\t\t});\n\n\t\t\t// applyOrder should renumber [rule-a, rule-b, rule-c] to [0, 1, 2]\n\t\t\texpect(defaultUpdateSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.anything(),\n\t\t\t\t{ id: 'rule-a' },\n\t\t\t\t{ order: 0 },\n\t\t\t);\n\t\t\texpect(defaultUpdateSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.anything(),\n\t\t\t\t{ id: 'rule-b' },\n\t\t\t\t{ order: 1 },\n\t\t\t);\n\t\t\texpect(defaultUpdateSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.anything(),\n\t\t\t\t{ id: 'rule-c' },\n\t\t\t\t{ order: 2 },\n\t\t\t);\n\t\t});\n\n\t\tit('should insert at index 0 and shift existing rules down', async () => {\n\t\t\troleRepository.findOne.mockResolvedValue(globalRole);\n\t\t\troleMappingRuleRepository.find.mockResolvedValue([\n\t\t\t\t{ id: 'rule-a', order: 0 } as RoleMappingRule,\n\t\t\t\t{ id: 'rule-b', order: 1 } as RoleMappingRule,\n\t\t\t]);\n\n\t\t\tconst savedRule = {\n\t\t\t\tid: 'rule-new',\n\t\t\t\texpression: 'claims.new',\n\t\t\t\trole: globalRole,\n\t\t\t\ttype: 'instance',\n\t\t\t\torder: 0,\n\t\t\t\tprojects: [],\n\t\t\t\tcreatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t\tupdatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t} as unknown as RoleMappingRule;\n\n\t\t\troleMappingRuleRepository.save.mockImplementation(async (r) => ({\n\t\t\t\t...(r as RoleMappingRule),\n\t\t\t\tid: 'rule-new',\n\t\t\t}));\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue(savedRule);\n\n\t\t\tawait service.create({\n\t\t\t\texpression: 'claims.new',\n\t\t\t\trole: globalRole.slug,\n\t\t\t\ttype: 'instance',\n\t\t\t\torder: 0,\n\t\t\t});\n\n\t\t\t// applyOrder should renumber [rule-new, rule-a, rule-b] to [0, 1, 2]\n\t\t\texpect(defaultUpdateSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.anything(),\n\t\t\t\t{ id: 'rule-new' },\n\t\t\t\t{ order: 0 },\n\t\t\t);\n\t\t\texpect(defaultUpdateSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.anything(),\n\t\t\t\t{ id: 'rule-a' },\n\t\t\t\t{ order: 1 },\n\t\t\t);\n\t\t\texpect(defaultUpdateSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.anything(),\n\t\t\t\t{ id: 'rule-b' },\n\t\t\t\t{ order: 2 },\n\t\t\t);\n\t\t});\n\n\t\tit('should clamp supplied order beyond existing list length to the end', async () => {\n\t\t\troleRepository.findOne.mockResolvedValue(globalRole);\n\t\t\troleMappingRuleRepository.find.mockResolvedValue([\n\t\t\t\t{ id: 'rule-a', order: 0 } as RoleMappingRule,\n\t\t\t]);\n\n\t\t\tconst savedRule = {\n\t\t\t\tid: 'rule-new',\n\t\t\t\texpression: 'claims.new',\n\t\t\t\trole: globalRole,\n\t\t\t\ttype: 'instance',\n\t\t\t\torder: 1,\n\t\t\t\tprojects: [],\n\t\t\t\tcreatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t\tupdatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t} as unknown as RoleMappingRule;\n\n\t\t\troleMappingRuleRepository.save.mockImplementation(async (r) => ({\n\t\t\t\t...(r as RoleMappingRule),\n\t\t\t\tid: 'rule-new',\n\t\t\t}));\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue(savedRule);\n\n\t\t\tawait service.create({\n\t\t\t\texpression: 'claims.new',\n\t\t\t\trole: globalRole.slug,\n\t\t\t\ttype: 'instance',\n\t\t\t\torder: 999,\n\t\t\t});\n\n\t\t\t// Clamped to end: [rule-a, rule-new] → orders [0, 1]\n\t\t\texpect(defaultUpdateSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.anything(),\n\t\t\t\t{ id: 'rule-a' },\n\t\t\t\t{ order: 0 },\n\t\t\t);\n\t\t\texpect(defaultUpdateSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.anything(),\n\t\t\t\t{ id: 'rule-new' },\n\t\t\t\t{ order: 1 },\n\t\t\t);\n\t\t});\n\n\t\tit('should let concurrent appends retry after sharing the same temporary order', async () => {\n\t\t\troleRepository.findOne.mockResolvedValue(globalRole);\n\t\t\troleMappingRuleRepository.find\n\t\t\t\t.mockResolvedValueOnce([{ id: 'rule-a', order: 0 } as RoleMappingRule])\n\t\t\t\t.mockResolvedValueOnce([{ id: 'rule-a', order: 0 } as RoleMappingRule])\n\t\t\t\t.mockResolvedValueOnce([\n\t\t\t\t\t{ id: 'rule-a', order: 0 } as RoleMappingRule,\n\t\t\t\t\t{ id: 'rule-b', order: 1 } as RoleMappingRule,\n\t\t\t\t]);\n\n\t\t\tconst uniqueConstraintError = new QueryFailedError(\n\t\t\t\t'INSERT INTO role_mapping_rule ...',\n\t\t\t\t[],\n\t\t\t\t{ code: 'SQLITE_CONSTRAINT' },\n\t\t\t);\n\n\t\t\troleMappingRuleRepository.save\n\t\t\t\t.mockImplementationOnce(async (r) => ({\n\t\t\t\t\t...(r as RoleMappingRule),\n\t\t\t\t\tid: 'rule-b',\n\t\t\t\t}))\n\t\t\t\t.mockRejectedValueOnce(uniqueConstraintError)\n\t\t\t\t.mockImplementationOnce(async (r) => ({\n\t\t\t\t\t...(r as RoleMappingRule),\n\t\t\t\t\tid: 'rule-new',\n\t\t\t\t}));\n\n\t\t\tconst savedRules = new Map(\n\t\t\t\t[\n\t\t\t\t\t['rule-b', 1],\n\t\t\t\t\t['rule-new', 2],\n\t\t\t\t].map(\n\t\t\t\t\t([id, order]) =>\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\texpression: `claims.${id}`,\n\t\t\t\t\t\t\t\trole: globalRole,\n\t\t\t\t\t\t\t\ttype: 'instance',\n\t\t\t\t\t\t\t\torder,\n\t\t\t\t\t\t\t\tprojects: [],\n\t\t\t\t\t\t\t\tcreatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t\t\t\t\t\tupdatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t\t\t\t\t} as unknown as RoleMappingRule,\n\t\t\t\t\t\t] as const,\n\t\t\t\t),\n\t\t\t);\n\t\t\troleMappingRuleRepository.findOneOrFail.mockImplementation(async (options) => {\n\t\t\t\tconst where = options.where as { id: string };\n\t\t\t\treturn savedRules.get(where.id) as RoleMappingRule;\n\t\t\t});\n\n\t\t\tconst [firstResult, secondResult] = await Promise.all([\n\t\t\t\tservice.create({\n\t\t\t\t\texpression: 'claims.rule-b',\n\t\t\t\t\trole: globalRole.slug,\n\t\t\t\t\ttype: 'instance',\n\t\t\t\t}),\n\t\t\t\tservice.create({\n\t\t\t\t\texpression: 'claims.rule-new',\n\t\t\t\t\trole: globalRole.slug,\n\t\t\t\t\ttype: 'instance',\n\t\t\t\t}),\n\t\t\t]);\n\n\t\t\texpect([firstResult.id, secondResult.id]).toEqual(['rule-b', 'rule-new']);\n\t\t\texpect(roleMappingRuleRepository.find).toHaveBeenCalledTimes(3);\n\t\t\texpect(roleMappingRuleRepository.save).toHaveBeenCalledTimes(3);\n\t\t\texpect(defaultUpdateSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.anything(),\n\t\t\t\t{ id: 'rule-a' },\n\t\t\t\t{ order: 0 },\n\t\t\t);\n\t\t\texpect(defaultUpdateSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.anything(),\n\t\t\t\t{ id: 'rule-b' },\n\t\t\t\t{ order: 1 },\n\t\t\t);\n\t\t\texpect(defaultUpdateSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.anything(),\n\t\t\t\t{ id: 'rule-new' },\n\t\t\t\t{ order: 2 },\n\t\t\t);\n\t\t});\n\t});\n\n\tdescribe('list', () => {\n\t\tit('should return paginated rules with default order sort', async () => {\n\t\t\tconst ruleA = {\n\t\t\t\tid: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',\n\t\t\t\texpression: 'a',\n\t\t\t\trole: globalRole,\n\t\t\t\ttype: 'instance',\n\t\t\t\torder: 0,\n\t\t\t\tprojects: [],\n\t\t\t\tcreatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t\tupdatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t} as unknown as RoleMappingRule;\n\n\t\t\troleMappingRuleRepository.findAndCount.mockResolvedValue([[ruleA], 1]);\n\n\t\t\tconst result = await service.list({ skip: 0, take: 10 });\n\n\t\t\texpect(result.count).toBe(1);\n\t\t\texpect(result.items).toHaveLength(1);\n\t\t\texpect(result.items[0]).toMatchObject({\n\t\t\t\tid: ruleA.id,\n\t\t\t\texpression: 'a',\n\t\t\t\torder: 0,\n\t\t\t\ttype: 'instance',\n\t\t\t});\n\n\t\t\texpect(roleMappingRuleRepository.findAndCount).toHaveBeenCalledWith({\n\t\t\t\twhere: {},\n\t\t\t\trelations: ['projects', 'role'],\n\t\t\t\torder: { order: 'ASC', id: 'ASC' },\n\t\t\t\tskip: 0,\n\t\t\t\ttake: 10,\n\t\t\t});\n\t\t});\n\n\t\tit('should filter by type and apply sortBy', async () => {\n\t\t\troleMappingRuleRepository.findAndCount.mockResolvedValue([[], 0]);\n\n\t\t\tawait service.list({\n\t\t\t\tskip: 0,\n\t\t\t\ttake: 5,\n\t\t\t\ttype: 'project',\n\t\t\t\tsortBy: 'createdAt:desc',\n\t\t\t});\n\n\t\t\texpect(roleMappingRuleRepository.findAndCount).toHaveBeenCalledWith({\n\t\t\t\twhere: { type: 'project' },\n\t\t\t\trelations: ['projects', 'role'],\n\t\t\t\torder: { createdAt: 'DESC', id: 'ASC' },\n\t\t\t\tskip: 0,\n\t\t\t\ttake: 5,\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe('patch', () => {\n\t\tconst existingInstanceRule = {\n\t\t\tid: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',\n\t\t\texpression: 'claims.group === \"admins\"',\n\t\t\trole: globalRole,\n\t\t\ttype: 'instance',\n\t\t\torder: 0,\n\t\t\tprojects: [],\n\t\t\tcreatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\tupdatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t} as unknown as RoleMappingRule;\n\n\t\tbeforeEach(() => {\n\t\t\troleMappingRuleRepository.findOne.mockImplementation(async (options) => {\n\t\t\t\tif (\n\t\t\t\t\toptions?.where &&\n\t\t\t\t\t'id' in options.where &&\n\t\t\t\t\toptions.where.id === existingInstanceRule.id\n\t\t\t\t) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...existingInstanceRule,\n\t\t\t\t\t\trole: globalRole,\n\t\t\t\t\t\tprojects: [],\n\t\t\t\t\t} as unknown as RoleMappingRule;\n\t\t\t\t}\n\t\t\t\treturn null;\n\t\t\t});\n\t\t});\n\n\t\tit('should return 404 when rule id is unknown', async () => {\n\t\t\tawait expect(\n\t\t\t\tservice.patch('00000000-0000-4000-8000-000000000000', { expression: 'true' }),\n\t\t\t).rejects.toThrow(NotFoundError);\n\t\t});\n\n\t\tit('should reject an empty patch payload', async () => {\n\t\t\tawait expect(service.patch(existingInstanceRule.id, {})).rejects.toThrow(BadRequestError);\n\t\t});\n\n\t\tit('should update expression and return loaded rule', async () => {\n\t\t\tconst updatedRule = {\n\t\t\t\t...existingInstanceRule,\n\t\t\t\texpression: 'claims.new === 1',\n\t\t\t\trole: globalRole,\n\t\t\t\tprojects: [],\n\t\t\t\tupdatedAt: new Date('2025-06-01T00:00:00.000Z'),\n\t\t\t} as unknown as RoleMappingRule;\n\n\t\t\troleMappingRuleRepository.save.mockImplementation(async (r) => r as RoleMappingRule);\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue(updatedRule);\n\n\t\t\tconst result = await service.patch(existingInstanceRule.id, {\n\t\t\t\texpression: 'claims.new === 1',\n\t\t\t});\n\n\t\t\texpect(result.expression).toBe('claims.new === 1');\n\t\t\texpect(result.role).toBe(globalRole.slug);\n\t\t\texpect(roleMappingRuleRepository.save).toHaveBeenCalledTimes(1);\n\t\t});\n\n\t\tit('should return 409 when order collides with another rule', async () => {\n\t\t\tconst otherId = 'b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22';\n\t\t\troleMappingRuleRepository.findOne.mockImplementation(async (options) => {\n\t\t\t\tif (\n\t\t\t\t\toptions?.where &&\n\t\t\t\t\t'id' in options.where &&\n\t\t\t\t\toptions.where.id === existingInstanceRule.id\n\t\t\t\t) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...existingInstanceRule,\n\t\t\t\t\t\trole: globalRole,\n\t\t\t\t\t\tprojects: [],\n\t\t\t\t\t} as unknown as RoleMappingRule;\n\t\t\t\t}\n\t\t\t\tif (\n\t\t\t\t\toptions?.where &&\n\t\t\t\t\t'type' in options.where &&\n\t\t\t\t\toptions.where.type === 'instance' &&\n\t\t\t\t\toptions.where.order === 5\n\t\t\t\t) {\n\t\t\t\t\treturn { id: otherId } as unknown as RoleMappingRule;\n\t\t\t\t}\n\t\t\t\treturn null;\n\t\t\t});\n\n\t\t\tawait expect(service.patch(existingInstanceRule.id, { order: 5 })).rejects.toThrow(\n\t\t\t\tConflictError,\n\t\t\t);\n\t\t});\n\n\t\tit('should allow patch that keeps the same type and order', async () => {\n\t\t\troleMappingRuleRepository.findOne.mockImplementation(async (options) => {\n\t\t\t\tif (\n\t\t\t\t\toptions?.where &&\n\t\t\t\t\t'id' in options.where &&\n\t\t\t\t\toptions.where.id === existingInstanceRule.id\n\t\t\t\t) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...existingInstanceRule,\n\t\t\t\t\t\trole: globalRole,\n\t\t\t\t\t\tprojects: [],\n\t\t\t\t\t} as unknown as RoleMappingRule;\n\t\t\t\t}\n\t\t\t\tif (\n\t\t\t\t\toptions?.where &&\n\t\t\t\t\t'type' in options.where &&\n\t\t\t\t\t'order' in options.where &&\n\t\t\t\t\toptions.where.type === 'instance' &&\n\t\t\t\t\toptions.where.order === 0\n\t\t\t\t) {\n\t\t\t\t\treturn { ...existingInstanceRule } as unknown as RoleMappingRule;\n\t\t\t\t}\n\t\t\t\treturn null;\n\t\t\t});\n\n\t\t\tconst updatedRule = {\n\t\t\t\t...existingInstanceRule,\n\t\t\t\texpression: 'true',\n\t\t\t\trole: globalRole,\n\t\t\t\tprojects: [],\n\t\t\t} as unknown as RoleMappingRule;\n\n\t\t\troleMappingRuleRepository.save.mockImplementation(async (r) => r as RoleMappingRule);\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue(updatedRule);\n\n\t\t\tawait expect(\n\t\t\t\tservice.patch(existingInstanceRule.id, { expression: 'true' }),\n\t\t\t).resolves.toMatchObject({ order: 0, type: 'instance' });\n\t\t});\n\t});\n\n\tdescribe('delete', () => {\n\t\tconst rule = {\n\t\t\tid: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',\n\t\t\texpression: 'true',\n\t\t\trole: globalRole,\n\t\t\ttype: 'instance',\n\t\t\torder: 0,\n\t\t\tprojects: [],\n\t\t\tcreatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\tupdatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t} as unknown as RoleMappingRule;\n\n\t\tit('should reject an empty id', async () => {\n\t\t\tawait expect(service.delete('')).rejects.toThrow(BadRequestError);\n\t\t});\n\n\t\tit('should return 404 when rule id is unknown', async () => {\n\t\t\troleMappingRuleRepository.findOne.mockResolvedValue(null);\n\n\t\t\tawait expect(service.delete('00000000-0000-4000-8000-000000000000')).rejects.toThrow(\n\t\t\t\tNotFoundError,\n\t\t\t);\n\t\t\texpect(roleMappingRuleRepository.remove).not.toHaveBeenCalled();\n\t\t});\n\n\t\tit('should remove the rule when it exists', async () => {\n\t\t\troleMappingRuleRepository.findOne.mockResolvedValue(rule);\n\t\t\troleMappingRuleRepository.remove.mockResolvedValue(rule);\n\n\t\t\tawait service.delete(rule.id);\n\n\t\t\texpect(roleMappingRuleRepository.remove).toHaveBeenCalledWith(rule);\n\t\t});\n\t});\n\n\tdescribe('deleteAllOfType', () => {\n\t\tit('should delete all rules of the given type using the direct repository when no tx is provided', async () => {\n\t\t\troleMappingRuleRepository.delete.mockResolvedValue({ affected: 3, raw: {} });\n\n\t\t\tconst count = await service.deleteAllOfType('project');\n\n\t\t\texpect(roleMappingRuleRepository.delete).toHaveBeenCalledWith({ type: 'project' });\n\t\t\texpect(count).toBe(3);\n\t\t});\n\n\t\tit('should use the transactional repository when an EntityManager is provided', async () => {\n\t\t\tconst txRepoDelete = jest.fn().mockResolvedValue({ affected: 2, raw: {} });\n\t\t\tconst txRepository = { delete: txRepoDelete };\n\t\t\tconst getRepository = jest.fn().mockReturnValue(txRepository);\n\t\t\tconst tx = { getRepository } as unknown as Parameters[1];\n\n\t\t\tconst count = await service.deleteAllOfType('instance', tx);\n\n\t\t\texpect(getRepository).toHaveBeenCalled();\n\t\t\texpect(txRepoDelete).toHaveBeenCalledWith({ type: 'instance' });\n\t\t\texpect(count).toBe(2);\n\t\t\texpect(roleMappingRuleRepository.delete).not.toHaveBeenCalled();\n\t\t});\n\n\t\tit('should return 0 when no rows match', async () => {\n\t\t\troleMappingRuleRepository.delete.mockResolvedValue({ affected: 0, raw: {} });\n\n\t\t\tconst count = await service.deleteAllOfType('project');\n\n\t\t\texpect(count).toBe(0);\n\t\t});\n\n\t\tit('should treat missing affected count as 0', async () => {\n\t\t\troleMappingRuleRepository.delete.mockResolvedValue({ affected: undefined, raw: {} });\n\n\t\t\tconst count = await service.deleteAllOfType('project');\n\n\t\t\texpect(count).toBe(0);\n\t\t});\n\t});\n\n\tdescribe('move', () => {\n\t\tconst updateSpy = jest.fn().mockResolvedValue(undefined);\n\t\tconst transactionSpy = jest.fn().mockImplementation(async (cb) => {\n\t\t\tawait cb({ update: updateSpy });\n\t\t});\n\n\t\tbeforeEach(() => {\n\t\t\t(roleMappingRuleRepository as unknown as Record).manager = {\n\t\t\t\ttransaction: transactionSpy,\n\t\t\t};\n\t\t\tupdateSpy.mockClear();\n\t\t\ttransactionSpy.mockClear();\n\t\t});\n\n\t\tconst makeRule = (id: string, order: number, type = 'instance') =>\n\t\t\t({\n\t\t\t\tid,\n\t\t\t\torder,\n\t\t\t\ttype,\n\t\t\t\trole: { slug: 'global:member' },\n\t\t\t\tprojects: [],\n\t\t\t}) as unknown as RoleMappingRule;\n\n\t\tit('should throw NotFoundError when rule does not exist', async () => {\n\t\t\troleMappingRuleRepository.findOne.mockResolvedValue(null);\n\n\t\t\tawait expect(service.move('nonexistent', 0)).rejects.toThrow(NotFoundError);\n\t\t});\n\n\t\tit('should move first rule to last position', async () => {\n\t\t\tconst rules = [makeRule('a', 0), makeRule('b', 1), makeRule('c', 2)];\n\t\t\troleMappingRuleRepository.findOne.mockResolvedValue(rules[0]);\n\t\t\troleMappingRuleRepository.find.mockResolvedValue(rules);\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue({\n\t\t\t\t...rules[0],\n\t\t\t\torder: 2,\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t} as unknown as RoleMappingRule);\n\n\t\t\tawait service.move('a', 2);\n\n\t\t\t// Verify applyOrder called with correct sequence: b, c, a\n\t\t\texpect(transactionSpy).toHaveBeenCalledTimes(1);\n\t\t});\n\n\t\tit('should move last rule to first position', async () => {\n\t\t\tconst rules = [makeRule('a', 0), makeRule('b', 1), makeRule('c', 2)];\n\t\t\troleMappingRuleRepository.findOne.mockResolvedValue(rules[2]);\n\t\t\troleMappingRuleRepository.find.mockResolvedValue(rules);\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue({\n\t\t\t\t...rules[2],\n\t\t\t\torder: 0,\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t} as unknown as RoleMappingRule);\n\n\t\t\tawait service.move('c', 0);\n\n\t\t\texpect(transactionSpy).toHaveBeenCalledTimes(1);\n\t\t});\n\n\t\tit('should clamp targetIndex to last position when out of bounds', async () => {\n\t\t\tconst rules = [makeRule('a', 0), makeRule('b', 1)];\n\t\t\troleMappingRuleRepository.findOne.mockResolvedValue(rules[0]);\n\t\t\troleMappingRuleRepository.find.mockResolvedValue(rules);\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue({\n\t\t\t\t...rules[0],\n\t\t\t\torder: 1,\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t} as unknown as RoleMappingRule);\n\n\t\t\t// targetIndex 999 should clamp to 1 (last valid index)\n\t\t\tawait service.move('a', 999);\n\n\t\t\texpect(transactionSpy).toHaveBeenCalledTimes(1);\n\t\t});\n\t});\n\n\tdescribe('normalizeOrderForType', () => {\n\t\tconst makeRule = (id: string, order: number, type = 'instance') =>\n\t\t\t({ id, order, type }) as unknown as RoleMappingRule;\n\n\t\tconst updateSpy = jest.fn().mockResolvedValue(undefined);\n\t\tconst transactionSpy = jest.fn();\n\n\t\tbeforeEach(() => {\n\t\t\tupdateSpy.mockClear();\n\t\t\ttransactionSpy.mockImplementation(\n\t\t\t\tasync (cb: (tx: { update: typeof updateSpy }) => Promise) => {\n\t\t\t\t\tawait cb({ update: updateSpy });\n\t\t\t\t},\n\t\t\t);\n\t\t\t// jest-mock-extended creates a Proxy; assigning manager directly\n\t\t\t// is the reliable way to inject the transaction mock.\n\t\t\t(roleMappingRuleRepository as unknown as Record).manager = {\n\t\t\t\ttransaction: transactionSpy,\n\t\t\t};\n\t\t});\n\n\t\tit('should not call transaction when sequence has no gaps', async () => {\n\t\t\troleMappingRuleRepository.find.mockResolvedValue([\n\t\t\t\tmakeRule('a', 0),\n\t\t\t\tmakeRule('b', 1),\n\t\t\t\tmakeRule('c', 2),\n\t\t\t]);\n\n\t\t\troleMappingRuleRepository.findOne.mockResolvedValue(makeRule('a', 0));\n\t\t\troleMappingRuleRepository.remove.mockResolvedValue(makeRule('a', 0));\n\n\t\t\tawait service.delete('a');\n\n\t\t\texpect(transactionSpy).not.toHaveBeenCalled();\n\t\t});\n\n\t\tit('should renumber rules to close a gap after delete', async () => {\n\t\t\t// Simulates [0, 2, 3] after deleting the rule at order 1\n\t\t\troleMappingRuleRepository.find.mockResolvedValue([\n\t\t\t\tmakeRule('a', 0),\n\t\t\t\tmakeRule('b', 2),\n\t\t\t\tmakeRule('c', 3),\n\t\t\t]);\n\n\t\t\troleMappingRuleRepository.findOne.mockResolvedValue(makeRule('x', 0));\n\t\t\troleMappingRuleRepository.remove.mockResolvedValue(makeRule('x', 0));\n\n\t\t\tawait service.delete('x');\n\n\t\t\texpect(transactionSpy).toHaveBeenCalledTimes(1);\n\n\t\t\t// Phase 2 should assign contiguous orders 0, 1, 2\n\t\t\texpect(updateSpy).toHaveBeenCalledWith(expect.anything(), { id: 'a' }, { order: 0 });\n\t\t\texpect(updateSpy).toHaveBeenCalledWith(expect.anything(), { id: 'b' }, { order: 1 });\n\t\t\texpect(updateSpy).toHaveBeenCalledWith(expect.anything(), { id: 'c' }, { order: 2 });\n\t\t});\n\n\t\tit('should normalize both types when type changes during patch', async () => {\n\t\t\tconst existingRule = {\n\t\t\t\tid: 'rule-1',\n\t\t\t\texpression: 'true',\n\t\t\t\trole: globalRole,\n\t\t\t\ttype: 'instance',\n\t\t\t\torder: 1,\n\t\t\t\tprojects: [],\n\t\t\t} as unknown as RoleMappingRule;\n\n\t\t\troleMappingRuleRepository.findOne.mockImplementation(async (opts) => {\n\t\t\t\tif (opts?.where && 'id' in opts.where) return existingRule;\n\t\t\t\treturn null;\n\t\t\t});\n\t\t\troleMappingRuleRepository.save.mockResolvedValue(existingRule);\n\t\t\troleMappingRuleRepository.findOneOrFail.mockResolvedValue({\n\t\t\t\t...existingRule,\n\t\t\t\ttype: 'project',\n\t\t\t\trole: projectRole,\n\t\t\t\tprojects: [{ id: 'p1' } as Project],\n\t\t\t\tcreatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t\tupdatedAt: new Date('2025-01-01T00:00:00.000Z'),\n\t\t\t} as unknown as RoleMappingRule);\n\t\t\tprojectRepository.findBy.mockResolvedValue([{ id: 'p1' } as Project]);\n\t\t\troleRepository.findOne.mockResolvedValue(projectRole);\n\n\t\t\troleMappingRuleRepository.find\n\t\t\t\t.mockResolvedValueOnce([makeRule('rule-1', 0, 'project')]) // new type: project — no gap\n\t\t\t\t.mockResolvedValueOnce([\n\t\t\t\t\tmakeRule('rule-2', 0, 'instance'),\n\t\t\t\t\tmakeRule('rule-3', 2, 'instance'),\n\t\t\t\t]); // old type: instance has gap\n\n\t\t\tawait service.patch('rule-1', {\n\t\t\t\ttype: 'project',\n\t\t\t\trole: projectRole.slug,\n\t\t\t\tprojectIds: ['p1'],\n\t\t\t\torder: 0,\n\t\t\t});\n\n\t\t\t// Called twice: once for new type (project), once for old type (instance)\n\t\t\texpect(roleMappingRuleRepository.find).toHaveBeenCalledTimes(2);\n\t\t\t// Project sequence has no gap — no transaction needed for it\n\t\t\t// Instance sequence has gap [0, 2] — transaction called once\n\t\t\texpect(transactionSpy).toHaveBeenCalledTimes(1);\n\t\t});\n\t});\n});\n" \ No newline at end of file diff --git a/packages/cli/src/modules/provisioning.ee/role-mapping-rule.service.ee.ts b/packages/cli/src/modules/provisioning.ee/role-mapping-rule.service.ee.ts index 31bfcf4a18b1c..65f288ec7861b 100644 --- a/packages/cli/src/modules/provisioning.ee/role-mapping-rule.service.ee.ts +++ b/packages/cli/src/modules/provisioning.ee/role-mapping-rule.service.ee.ts @@ -1,341 +1 @@ -import { - CreateRoleMappingRuleDto, - type ListRoleMappingRuleQueryInput, - type PatchRoleMappingRuleInput, -} from '@n8n/api-types'; -import { - ProjectRepository, - RoleMappingRule, - RoleMappingRuleRepository, - RoleRepository, -} from '@n8n/db'; -import { Service } from '@n8n/di'; - -import { type EntityManager, type FindOptionsOrder, In } from '@n8n/typeorm'; -import type { z } from 'zod'; - -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; - -import { - assertAndNormalizeProjectIdsForRuleType, - assertRoleCompatibleWithMappingType, -} from './role-mapping-rule.validation'; - -type CreateRoleMappingRuleInput = z.infer<(typeof CreateRoleMappingRuleDto)['schema']>; - -export type RoleMappingRuleResponse = { - id: string; - expression: string; - role: string; - type: 'instance' | 'project'; - order: number; - projectIds: string[]; - createdAt: string; - updatedAt: string; -}; - -export type RoleMappingRuleListResponse = { - count: number; - items: RoleMappingRuleResponse[]; -}; - -@Service() -export class RoleMappingRuleService { - constructor( - private readonly roleMappingRuleRepository: RoleMappingRuleRepository, - private readonly roleRepository: RoleRepository, - private readonly projectRepository: ProjectRepository, - ) {} - - async list(query: ListRoleMappingRuleQueryInput): Promise { - const sortBy = query.sortBy ?? 'order:asc'; - const [sortField, sortDir] = sortBy.split(':') as [ - 'order' | 'createdAt' | 'updatedAt', - 'asc' | 'desc', - ]; - const direction: 'ASC' | 'DESC' = sortDir === 'desc' ? 'DESC' : 'ASC'; - - const order: FindOptionsOrder = - sortField === 'createdAt' - ? { createdAt: direction, id: 'ASC' } - : sortField === 'updatedAt' - ? { updatedAt: direction, id: 'ASC' } - : { order: direction, id: 'ASC' }; - - const where = query.type ? { type: query.type } : {}; - - const [entities, count] = await this.roleMappingRuleRepository.findAndCount({ - where, - relations: ['projects', 'role'], - order, - skip: query.skip, - take: query.take, - }); - - return { - count, - items: entities.map((entity) => this.toResponse(entity)), - }; - } - - async create(dto: CreateRoleMappingRuleInput): Promise { - const uniqueProjectIds = assertAndNormalizeProjectIdsForRuleType(dto.type, dto.projectIds, []); - - const role = await this.roleRepository.findOne({ where: { slug: dto.role } }); - if (!role) { - throw new NotFoundError(`Could not find role with slug "${dto.role}"`); - } - - assertRoleCompatibleWithMappingType(role, dto.type); - - const projects = - uniqueProjectIds.length > 0 - ? await this.projectRepository.findBy({ id: In(uniqueProjectIds) }) - : []; - - if (projects.length !== uniqueProjectIds.length) { - throw new BadRequestError('One or more projects were not found'); - } - - const existingRules = await this.roleMappingRuleRepository.find({ - where: { type: dto.type }, - select: ['id', 'order'], - order: { order: 'ASC' }, - }); - - // Clamp the requested index into the valid range. Omitted order appends. - const requestedOrder = dto.order ?? existingRules.length; - const targetIndex = Math.min(Math.max(requestedOrder, 0), existingRules.length); - - // Save the new rule at a temporary order beyond any currently-used slot, - // so the unique (type, order) constraint cannot fire on the initial insert. - const maxOrder = existingRules.length > 0 ? existingRules[existingRules.length - 1].order : -1; - const tempOrder = Math.max(maxOrder, existingRules.length - 1) + 1; - - const rule = new RoleMappingRule(); - rule.expression = dto.expression; - rule.role = role; - rule.type = dto.type; - rule.order = tempOrder; - rule.projects = projects; - - const saved = await this.roleMappingRuleRepository.save(rule); - - // Build the final ordering: existing rules in their current order, with the - // newly saved rule spliced in at targetIndex. applyOrder atomically renumbers - // everything to [0..n-1] using its two-phase transaction. - const reorderedIds = existingRules.map((r) => r.id); - reorderedIds.splice(targetIndex, 0, saved.id); - - await this.applyOrder(reorderedIds); - - const loaded = await this.roleMappingRuleRepository.findOneOrFail({ - where: { id: saved.id }, - relations: ['projects', 'role'], - }); - - return this.toResponse(loaded); - } - - async patch(id: string, dto: PatchRoleMappingRuleInput): Promise { - if (typeof id !== 'string' || id.length === 0) { - throw new BadRequestError('Rule id is required'); - } - - if (dto === undefined || dto === null || Object.keys(dto).length === 0) { - throw new BadRequestError('At least one field is required'); - } - - const rule = await this.roleMappingRuleRepository.findOne({ - where: { id }, - relations: ['projects', 'role'], - }); - - if (!rule) { - throw new NotFoundError('Could not find role mapping rule'); - } - - const originalType = rule.type as 'instance' | 'project'; - const mergedType = dto.type ?? originalType; - const mergedOrder = dto.order ?? rule.order; - const mergedExpression = dto.expression ?? rule.expression; - const mergedRoleSlug = dto.role ?? rule.role.slug; - - const fallbackProjectIds = rule.projects.map((p) => p.id); - const uniqueProjectIds = assertAndNormalizeProjectIdsForRuleType( - mergedType, - dto.projectIds, - fallbackProjectIds, - ); - - const role = - mergedRoleSlug === rule.role.slug - ? rule.role - : await this.roleRepository.findOne({ where: { slug: mergedRoleSlug } }); - - if (!role) { - throw new NotFoundError(`Could not find role with slug "${mergedRoleSlug}"`); - } - - assertRoleCompatibleWithMappingType(role, mergedType); - - await this.assertOrderAvailable(mergedType, mergedOrder, id); - - const projects = - uniqueProjectIds.length > 0 - ? await this.projectRepository.findBy({ id: In(uniqueProjectIds) }) - : []; - - if (projects.length !== uniqueProjectIds.length) { - throw new BadRequestError('One or more projects were not found'); - } - - rule.expression = mergedExpression; - rule.role = role; - rule.type = mergedType; - rule.order = mergedOrder; - rule.projects = projects; - - await this.roleMappingRuleRepository.save(rule); - - await this.normalizeOrderForType(mergedType); - if (originalType !== mergedType) { - await this.normalizeOrderForType(originalType); - } - - const loaded = await this.roleMappingRuleRepository.findOneOrFail({ - where: { id: rule.id }, - relations: ['projects', 'role'], - }); - - return this.toResponse(loaded); - } - - async delete(id: string): Promise<{ ruleType: 'instance' | 'project' }> { - if (typeof id !== 'string' || id.length === 0) { - throw new BadRequestError('Rule id is required'); - } - - const rule = await this.roleMappingRuleRepository.findOne({ where: { id } }); - - if (!rule) { - throw new NotFoundError('Could not find role mapping rule'); - } - - const ruleType = rule.type as 'instance' | 'project'; - await this.roleMappingRuleRepository.remove(rule); - await this.normalizeOrderForType(ruleType); - return { ruleType }; - } - - async deleteAllOfType(type: 'instance' | 'project', tx?: EntityManager): Promise { - const repo = tx ? tx.getRepository(RoleMappingRule) : this.roleMappingRuleRepository; - const result = await repo.delete({ type }); - return result.affected ?? 0; - } - - async move(id: string, targetIndex: number): Promise { - if (typeof id !== 'string' || id.length === 0) { - throw new BadRequestError('Rule id is required'); - } - - const rule = await this.roleMappingRuleRepository.findOne({ - where: { id }, - relations: ['projects', 'role'], - }); - - if (!rule) { - throw new NotFoundError('Could not find role mapping rule'); - } - - const type = rule.type as 'instance' | 'project'; - - const all = await this.roleMappingRuleRepository.find({ - where: { type }, - select: ['id', 'order'], - order: { order: 'ASC' }, - }); - - const clampedIndex = Math.min(targetIndex, all.length - 1); - const currentIndex = all.findIndex((r) => r.id === id); - - const reordered = [...all]; - reordered.splice(currentIndex, 1); - reordered.splice(clampedIndex, 0, all[currentIndex]); - - await this.applyOrder(reordered.map((r) => r.id)); - - const loaded = await this.roleMappingRuleRepository.findOneOrFail({ - where: { id }, - relations: ['projects', 'role'], - }); - - return this.toResponse(loaded); - } - - private async applyOrder(orderedIds: string[]): Promise { - if (orderedIds.length === 0) return; - - await this.roleMappingRuleRepository.manager.transaction(async (tx) => { - const offset = orderedIds.length + 1000; - - // Phase 1: move all rules to high offset values to vacate the target slots. - // This avoids transient unique constraint violations on (type, order) when - // shifting rules into positions that are already occupied. - for (let i = 0; i < orderedIds.length; i++) { - await tx.update(RoleMappingRule, { id: orderedIds[i] }, { order: offset + i }); - } - - // Phase 2: assign the final sequential order values starting from 0. - for (let i = 0; i < orderedIds.length; i++) { - await tx.update(RoleMappingRule, { id: orderedIds[i] }, { order: i }); - } - }); - } - - private async normalizeOrderForType(type: 'instance' | 'project'): Promise { - const rules = await this.roleMappingRuleRepository.find({ - where: { type }, - select: ['id', 'order'], - order: { order: 'ASC' }, - }); - - if (rules.length === 0) return; - - // Early exit: already a contiguous sequence starting at 0 - if (rules.every((r, i) => r.order === i)) return; - - await this.applyOrder(rules.map((r) => r.id)); - } - - private async assertOrderAvailable( - type: 'instance' | 'project', - order: number, - excludeRuleId?: string, - ): Promise { - const existingAtOrder = await this.roleMappingRuleRepository.findOne({ - where: { type, order }, - }); - - if (existingAtOrder && existingAtOrder.id !== excludeRuleId) { - throw new ConflictError( - `A role mapping rule already exists with type "${type}" and order ${order}. Use a different order value.`, - ); - } - } - - private toResponse(loaded: RoleMappingRule): RoleMappingRuleResponse { - return { - id: loaded.id, - expression: loaded.expression, - role: loaded.role.slug, - type: loaded.type as 'instance' | 'project', - order: loaded.order, - projectIds: loaded.projects.map((p) => p.id), - createdAt: loaded.createdAt.toISOString(), - updatedAt: loaded.updatedAt.toISOString(), - }; - } -} +"import {\n\tCreateRoleMappingRuleDto,\n\ttype ListRoleMappingRuleQueryInput,\n\ttype PatchRoleMappingRuleInput,\n} from '@n8n/api-types';\nimport {\n\tProjectRepository,\n\tRoleMappingRule,\n\tRoleMappingRuleRepository,\n\tRoleRepository,\n} from '@n8n/db';\nimport { Service } from '@n8n/di';\n\nimport { type EntityManager, type FindOptionsOrder, In, QueryFailedError } from '@n8n/typeorm';\nimport type { z } from 'zod';\n\nimport { BadRequestError } from '@/errors/response-errors/bad-request.error';\nimport { ConflictError } from '@/errors/response-errors/conflict.error';\nimport { NotFoundError } from '@/errors/response-errors/not-found.error';\n\nimport {\n\tassertAndNormalizeProjectIdsForRuleType,\n\tassertRoleCompatibleWithMappingType,\n} from './role-mapping-rule.validation';\n\ntype CreateRoleMappingRuleInput = z.infer<(typeof CreateRoleMappingRuleDto)['schema']>;\n\nconst MAX_CREATE_ORDER_RETRIES = 3;\n\nfunction isUniqueConstraintError(error: unknown): boolean {\n\tif (!(error instanceof QueryFailedError)) return false;\n\n\tconst driverError = error.driverError as\n\t\t| { code?: string; errno?: number; message?: string }\n\t\t| undefined;\n\tconst code = driverError?.code;\n\treturn (\n\t\tcode?.startsWith('SQLITE_CONSTRAINT') === true ||\n\t\tcode === '23505' ||\n\t\tcode === 'ER_DUP_ENTRY' ||\n\t\tdriverError?.errno === 1062 ||\n\t\tdriverError?.message?.toLowerCase().includes('duplicate key') === true\n\t);\n}\n\nexport type RoleMappingRuleResponse = {\n\tid: string;\n\texpression: string;\n\trole: string;\n\ttype: 'instance' | 'project';\n\torder: number;\n\tprojectIds: string[];\n\tcreatedAt: string;\n\tupdatedAt: string;\n};\n\nexport type RoleMappingRuleListResponse = {\n\tcount: number;\n\titems: RoleMappingRuleResponse[];\n};\n\n@Service()\nexport class RoleMappingRuleService {\n\tconstructor(\n\t\tprivate readonly roleMappingRuleRepository: RoleMappingRuleRepository,\n\t\tprivate readonly roleRepository: RoleRepository,\n\t\tprivate readonly projectRepository: ProjectRepository,\n\t) {}\n\n\tasync list(query: ListRoleMappingRuleQueryInput): Promise {\n\t\tconst sortBy = query.sortBy ?? 'order:asc';\n\t\tconst [sortField, sortDir] = sortBy.split(':') as [\n\t\t\t'order' | 'createdAt' | 'updatedAt',\n\t\t\t'asc' | 'desc',\n\t\t];\n\t\tconst direction: 'ASC' | 'DESC' = sortDir === 'desc' ? 'DESC' : 'ASC';\n\n\t\tconst order: FindOptionsOrder =\n\t\t\tsortField === 'createdAt'\n\t\t\t\t? { createdAt: direction, id: 'ASC' }\n\t\t\t\t: sortField === 'updatedAt'\n\t\t\t\t\t? { updatedAt: direction, id: 'ASC' }\n\t\t\t\t\t: { order: direction, id: 'ASC' };\n\n\t\tconst where = query.type ? { type: query.type } : {};\n\n\t\tconst [entities, count] = await this.roleMappingRuleRepository.findAndCount({\n\t\t\twhere,\n\t\t\trelations: ['projects', 'role'],\n\t\t\torder,\n\t\t\tskip: query.skip,\n\t\t\ttake: query.take,\n\t\t});\n\n\t\treturn {\n\t\t\tcount,\n\t\t\titems: entities.map((entity) => this.toResponse(entity)),\n\t\t};\n\t}\n\n\tasync create(dto: CreateRoleMappingRuleInput): Promise {\n\t\tconst uniqueProjectIds = assertAndNormalizeProjectIdsForRuleType(dto.type, dto.projectIds, []);\n\n\t\tconst role = await this.roleRepository.findOne({ where: { slug: dto.role } });\n\t\tif (!role) {\n\t\t\tthrow new NotFoundError(`Could not find role with slug \"${dto.role}\"`);\n\t\t}\n\n\t\tassertRoleCompatibleWithMappingType(role, dto.type);\n\n\t\tconst projects =\n\t\t\tuniqueProjectIds.length > 0\n\t\t\t\t? await this.projectRepository.findBy({ id: In(uniqueProjectIds) })\n\t\t\t\t: [];\n\n\t\tif (projects.length !== uniqueProjectIds.length) {\n\t\t\tthrow new BadRequestError('One or more projects were not found');\n\t\t}\n\n\t\tfor (let attempt = 0; attempt < MAX_CREATE_ORDER_RETRIES; attempt++) {\n\t\t\ttry {\n\t\t\t\treturn await this.roleMappingRuleRepository.manager.transaction(async (tx) => {\n\t\t\t\t\tconst roleMappingRuleRepository = tx.getRepository(RoleMappingRule);\n\n\t\t\t\t\tconst existingRules = await roleMappingRuleRepository.find({\n\t\t\t\t\t\twhere: { type: dto.type },\n\t\t\t\t\t\tselect: ['id', 'order'],\n\t\t\t\t\t\torder: { order: 'ASC' },\n\t\t\t\t\t});\n\n\t\t\t\t\t// Clamp the requested index into the valid range. Omitted order appends.\n\t\t\t\t\tconst requestedOrder = dto.order ?? existingRules.length;\n\t\t\t\t\tconst targetIndex = Math.min(Math.max(requestedOrder, 0), existingRules.length);\n\n\t\t\t\t\t// Save the new rule at a temporary order beyond any currently-used slot,\n\t\t\t\t\t// so the unique (type, order) constraint cannot fire on the initial insert.\n\t\t\t\t\t// Concurrent creates can still choose the same temporary slot from the same\n\t\t\t\t\t// snapshot, so retry after the transaction rolls back on that unique conflict.\n\t\t\t\t\tconst maxOrder =\n\t\t\t\t\t\texistingRules.length > 0 ? existingRules[existingRules.length - 1].order : -1;\n\t\t\t\t\tconst tempOrder = Math.max(maxOrder, existingRules.length - 1) + 1;\n\n\t\t\t\t\tconst rule = new RoleMappingRule();\n\t\t\t\t\trule.expression = dto.expression;\n\t\t\t\t\trule.role = role;\n\t\t\t\t\trule.type = dto.type;\n\t\t\t\t\trule.order = tempOrder;\n\t\t\t\t\trule.projects = projects;\n\n\t\t\t\t\tconst saved = await roleMappingRuleRepository.save(rule);\n\n\t\t\t\t\t// Build the final ordering: existing rules in their current order, with the\n\t\t\t\t\t// newly saved rule spliced in at targetIndex. applyOrder atomically renumbers\n\t\t\t\t\t// everything to [0..n-1] using the same transaction as the insert.\n\t\t\t\t\tconst reorderedIds = existingRules.map((r) => r.id);\n\t\t\t\t\treorderedIds.splice(targetIndex, 0, saved.id);\n\n\t\t\t\t\tawait this.applyOrder(reorderedIds, tx);\n\n\t\t\t\t\tconst loaded = await roleMappingRuleRepository.findOneOrFail({\n\t\t\t\t\t\twhere: { id: saved.id },\n\t\t\t\t\t\trelations: ['projects', 'role'],\n\t\t\t\t\t});\n\n\t\t\t\t\treturn this.toResponse(loaded);\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tif (!isUniqueConstraintError(error)) throw error;\n\t\t\t}\n\t\t}\n\n\t\tthrow new ConflictError('Could not create role mapping rule due to concurrent order updates');\n\t}\n\n\tasync patch(id: string, dto: PatchRoleMappingRuleInput): Promise {\n\t\tif (typeof id !== 'string' || id.length === 0) {\n\t\t\tthrow new BadRequestError('Rule id is required');\n\t\t}\n\n\t\tif (dto === undefined || dto === null || Object.keys(dto).length === 0) {\n\t\t\tthrow new BadRequestError('At least one field is required');\n\t\t}\n\n\t\tconst rule = await this.roleMappingRuleRepository.findOne({\n\t\t\twhere: { id },\n\t\t\trelations: ['projects', 'role'],\n\t\t});\n\n\t\tif (!rule) {\n\t\t\tthrow new NotFoundError('Could not find role mapping rule');\n\t\t}\n\n\t\tconst originalType = rule.type as 'instance' | 'project';\n\t\tconst mergedType = dto.type ?? originalType;\n\t\tconst mergedOrder = dto.order ?? rule.order;\n\t\tconst mergedExpression = dto.expression ?? rule.expression;\n\t\tconst mergedRoleSlug = dto.role ?? rule.role.slug;\n\n\t\tconst fallbackProjectIds = rule.projects.map((p) => p.id);\n\t\tconst uniqueProjectIds = assertAndNormalizeProjectIdsForRuleType(\n\t\t\tmergedType,\n\t\t\tdto.projectIds,\n\t\t\tfallbackProjectIds,\n\t\t);\n\n\t\tconst role =\n\t\t\tmergedRoleSlug === rule.role.slug\n\t\t\t\t? rule.role\n\t\t\t\t: await this.roleRepository.findOne({ where: { slug: mergedRoleSlug } });\n\n\t\tif (!role) {\n\t\t\tthrow new NotFoundError(`Could not find role with slug \"${mergedRoleSlug}\"`);\n\t\t}\n\n\t\tassertRoleCompatibleWithMappingType(role, mergedType);\n\n\t\tawait this.assertOrderAvailable(mergedType, mergedOrder, id);\n\n\t\tconst projects =\n\t\t\tuniqueProjectIds.length > 0\n\t\t\t\t? await this.projectRepository.findBy({ id: In(uniqueProjectIds) })\n\t\t\t\t: [];\n\n\t\tif (projects.length !== uniqueProjectIds.length) {\n\t\t\tthrow new BadRequestError('One or more projects were not found');\n\t\t}\n\n\t\trule.expression = mergedExpression;\n\t\trule.role = role;\n\t\trule.type = mergedType;\n\t\trule.order = mergedOrder;\n\t\trule.projects = projects;\n\n\t\tawait this.roleMappingRuleRepository.save(rule);\n\n\t\tawait this.normalizeOrderForType(mergedType);\n\t\tif (originalType !== mergedType) {\n\t\t\tawait this.normalizeOrderForType(originalType);\n\t\t}\n\n\t\tconst loaded = await this.roleMappingRuleRepository.findOneOrFail({\n\t\t\twhere: { id: rule.id },\n\t\t\trelations: ['projects', 'role'],\n\t\t});\n\n\t\treturn this.toResponse(loaded);\n\t}\n\n\tasync delete(id: string): Promise<{ ruleType: 'instance' | 'project' }> {\n\t\tif (typeof id !== 'string' || id.length === 0) {\n\t\t\tthrow new BadRequestError('Rule id is required');\n\t\t}\n\n\t\tconst rule = await this.roleMappingRuleRepository.findOne({ where: { id } });\n\n\t\tif (!rule) {\n\t\t\tthrow new NotFoundError('Could not find role mapping rule');\n\t\t}\n\n\t\tconst ruleType = rule.type as 'instance' | 'project';\n\t\tawait this.roleMappingRuleRepository.remove(rule);\n\t\tawait this.normalizeOrderForType(ruleType);\n\t\treturn { ruleType };\n\t}\n\n\tasync deleteAllOfType(type: 'instance' | 'project', tx?: EntityManager): Promise {\n\t\tconst repo = tx ? tx.getRepository(RoleMappingRule) : this.roleMappingRuleRepository;\n\t\tconst result = await repo.delete({ type });\n\t\treturn result.affected ?? 0;\n\t}\n\n\tasync move(id: string, targetIndex: number): Promise {\n\t\tif (typeof id !== 'string' || id.length === 0) {\n\t\t\tthrow new BadRequestError('Rule id is required');\n\t\t}\n\n\t\tconst rule = await this.roleMappingRuleRepository.findOne({\n\t\t\twhere: { id },\n\t\t\trelations: ['projects', 'role'],\n\t\t});\n\n\t\tif (!rule) {\n\t\t\tthrow new NotFoundError('Could not find role mapping rule');\n\t\t}\n\n\t\tconst type = rule.type as 'instance' | 'project';\n\n\t\tconst all = await this.roleMappingRuleRepository.find({\n\t\t\twhere: { type },\n\t\t\tselect: ['id', 'order'],\n\t\t\torder: { order: 'ASC' },\n\t\t});\n\n\t\tconst clampedIndex = Math.min(targetIndex, all.length - 1);\n\t\tconst currentIndex = all.findIndex((r) => r.id === id);\n\n\t\tconst reordered = [...all];\n\t\treordered.splice(currentIndex, 1);\n\t\treordered.splice(clampedIndex, 0, all[currentIndex]);\n\n\t\tawait this.applyOrder(reordered.map((r) => r.id));\n\n\t\tconst loaded = await this.roleMappingRuleRepository.findOneOrFail({\n\t\t\twhere: { id },\n\t\t\trelations: ['projects', 'role'],\n\t\t});\n\n\t\treturn this.toResponse(loaded);\n\t}\n\n\tprivate async applyOrder(orderedIds: string[], tx?: EntityManager): Promise {\n\t\tif (orderedIds.length === 0) return;\n\n\t\tif (tx) {\n\t\t\tawait this.applyOrderInTransaction(orderedIds, tx);\n\t\t\treturn;\n\t\t}\n\n\t\tawait this.roleMappingRuleRepository.manager.transaction(async (manager) => {\n\t\t\tawait this.applyOrderInTransaction(orderedIds, manager);\n\t\t});\n\t}\n\n\tprivate async applyOrderInTransaction(orderedIds: string[], tx: EntityManager): Promise {\n\t\tconst offset = orderedIds.length + 1000;\n\n\t\t// Phase 1: move all rules to high offset values to vacate the target slots.\n\t\t// This avoids transient unique constraint violations on (type, order) when\n\t\t// shifting rules into positions that are already occupied.\n\t\tfor (let i = 0; i < orderedIds.length; i++) {\n\t\t\tawait tx.update(RoleMappingRule, { id: orderedIds[i] }, { order: offset + i });\n\t\t}\n\n\t\t// Phase 2: assign the final sequential order values starting from 0.\n\t\tfor (let i = 0; i < orderedIds.length; i++) {\n\t\t\tawait tx.update(RoleMappingRule, { id: orderedIds[i] }, { order: i });\n\t\t}\n\t}\n\n\tprivate async normalizeOrderForType(type: 'instance' | 'project'): Promise {\n\t\tconst rules = await this.roleMappingRuleRepository.find({\n\t\t\twhere: { type },\n\t\t\tselect: ['id', 'order'],\n\t\t\torder: { order: 'ASC' },\n\t\t});\n\n\t\tif (rules.length === 0) return;\n\n\t\t// Early exit: already a contiguous sequence starting at 0\n\t\tif (rules.every((r, i) => r.order === i)) return;\n\n\t\tawait this.applyOrder(rules.map((r) => r.id));\n\t}\n\n\tprivate async assertOrderAvailable(\n\t\ttype: 'instance' | 'project',\n\t\torder: number,\n\t\texcludeRuleId?: string,\n\t): Promise {\n\t\tconst existingAtOrder = await this.roleMappingRuleRepository.findOne({\n\t\t\twhere: { type, order },\n\t\t});\n\n\t\tif (existingAtOrder && existingAtOrder.id !== excludeRuleId) {\n\t\t\tthrow new ConflictError(\n\t\t\t\t`A role mapping rule already exists with type \"${type}\" and order ${order}. Use a different order value.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate toResponse(loaded: RoleMappingRule): RoleMappingRuleResponse {\n\t\treturn {\n\t\t\tid: loaded.id,\n\t\t\texpression: loaded.expression,\n\t\t\trole: loaded.role.slug,\n\t\t\ttype: loaded.type as 'instance' | 'project',\n\t\t\torder: loaded.order,\n\t\t\tprojectIds: loaded.projects.map((p) => p.id),\n\t\t\tcreatedAt: loaded.createdAt.toISOString(),\n\t\t\tupdatedAt: loaded.updatedAt.toISOString(),\n\t\t};\n\t}\n}\n" \ No newline at end of file