From 1bb15ff912338be9404a948cc2c5ff940c0a9e4d Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Mon, 3 Aug 2026 09:58:49 +0200 Subject: [PATCH] feat(APP-1003): Add permission entity domain types, generators, and name utils --- .../domain/allowedAction.ts | 8 + ...SppPermissionCheckProposalCreation.test.ts | 361 ++++++------------ .../types/enum/sppVotingBodyBrandIdentity.ts | 6 +- .../api/daoService/domain/daoPermission.ts | 71 +++- .../shared/api/daoService/domain/daoPlugin.ts | 15 + .../api/daoService/domain/enum/index.ts | 6 + .../domain/enum/permissionEntity.ts | 26 ++ .../src/shared/api/daoService/domain/index.ts | 17 +- .../generators/daoPermission.test.ts | 55 +++ .../testUtils/generators/daoPermission.ts | 32 +- .../permissionNameUtils.test.ts | 35 ++ .../permissionNameUtils.ts | 23 ++ 12 files changed, 407 insertions(+), 248 deletions(-) create mode 100644 apps/app/src/shared/api/daoService/domain/enum/permissionEntity.ts create mode 100644 apps/app/src/shared/testUtils/generators/daoPermission.test.ts diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts b/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts index d5233a8d53..6dad6d5246 100644 --- a/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts +++ b/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts @@ -1,6 +1,14 @@ import type { IAllowedActionDecoded } from './allowedActionDecoded'; export interface IAllowedAction { + /** + * Unique backend identifier of the allowed-action event. + */ + id: string; + /** + * Address of the condition contract that gates this action. + */ + conditionAddress: string; /** * Selector of the allowed action. `null` means native transfer. */ diff --git a/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts index 638bd5c014..a3091c0fbc 100644 --- a/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts +++ b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts @@ -108,12 +108,21 @@ describe('useSppPermissionCheckProposalCreation', () => { ...result, }); + const renderGuard = (params: ReturnType) => + renderHook(() => + useSppPermissionCheckProposalCreation( + params as Parameters< + typeof useSppPermissionCheckProposalCreation + >[0], + ), + ); + const mockSimulation = (result: { + isError: boolean; isLoading: boolean; - isSuccess: boolean; + result?: 'success' | 'failure'; }) => useSimulateProposalCreationSpy.mockReturnValue({ - isError: false, ...result, } as ReturnType< typeof useSimulateProposalModule.useSimulateProposalCreation @@ -127,15 +136,9 @@ describe('useSppPermissionCheckProposalCreation', () => { hasPermission: true, }); const params = createTestParams([guardResult]); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); - const { result } = renderHook(() => - useSppPermissionCheckProposalCreation( - params as Parameters< - typeof useSppPermissionCheckProposalCreation - >[0], - ), - ); + const { result } = renderGuard(params); expect(result.current.isRestricted).toBeTruthy(); expect(result.current.settings).toEqual(settings); @@ -146,15 +149,9 @@ describe('useSppPermissionCheckProposalCreation', () => { generateGuardResult({ isRestricted: false }), generateGuardResult({ isRestricted: false }), ]); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); - const { result } = renderHook(() => - useSppPermissionCheckProposalCreation( - params as Parameters< - typeof useSppPermissionCheckProposalCreation - >[0], - ), - ); + const { result } = renderGuard(params); expect(result.current.isRestricted).toBeFalsy(); }); @@ -170,15 +167,9 @@ describe('useSppPermissionCheckProposalCreation', () => { settings: restrictedSettings, }), ]); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); - const { result } = renderHook(() => - useSppPermissionCheckProposalCreation( - params as Parameters< - typeof useSppPermissionCheckProposalCreation - >[0], - ), - ); + const { result } = renderGuard(params); expect(result.current.isRestricted).toBeTruthy(); expect(result.current.settings).toEqual(restrictedSettings); @@ -188,15 +179,9 @@ describe('useSppPermissionCheckProposalCreation', () => { const params = createTestParams([ generateGuardResult({ isRestricted: true }), ]); - mockSimulation({ isLoading: false, isSuccess: false }); + mockSimulation({ isError: false, isLoading: false, result: 'failure' }); - const { result } = renderHook(() => - useSppPermissionCheckProposalCreation( - params as Parameters< - typeof useSppPermissionCheckProposalCreation - >[0], - ), - ); + const { result } = renderGuard(params); expect(result.current.hasPermission).toBeFalsy(); expect(result.current.isRestricted).toBeTruthy(); @@ -204,15 +189,9 @@ describe('useSppPermissionCheckProposalCreation', () => { it('returns isLoading true while the simulation is loading', () => { const params = createTestParams([generateGuardResult()]); - mockSimulation({ isLoading: true, isSuccess: false }); + mockSimulation({ isError: false, isLoading: true }); - const { result } = renderHook(() => - useSppPermissionCheckProposalCreation( - params as Parameters< - typeof useSppPermissionCheckProposalCreation - >[0], - ), - ); + const { result } = renderGuard(params); expect(result.current.isLoading).toBeTruthy(); }); @@ -252,38 +231,73 @@ describe('useSppPermissionCheckProposalCreation', () => { return { daoId: 'dao-test', plugin: sppPlugin }; }; - it('surfaces a Safe body settings group when it can create proposals', () => { - const safeAddress = `0x${'b'.repeat(40)}`; - const params = createSafeTestParams({ + // The expected settings group for a Safe body that can create proposals: the same shape + // regardless of whether the Safe comes from a stage body or from externalProposers. + const buildExpectedSafeGroup = (address: string) => [ + { + term: 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.pluginLabelName', + definition: addressUtils.truncateAddress(address), + link: { + href: `https://etherscan.io/address/${address}`, + isExternal: true, + }, + }, + { + term: 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.function', + definition: + 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.requirement', + }, + ]; + + const createStageBodySafeFixture = () => ({ + address: `0x${'b'.repeat(40)}`, + params: createSafeTestParams({ proposalCreationConditionAddress: `0x${'c'.repeat(40)}`, + }), + }); + + const createExternalProposerSafeFixture = () => { + const address = `0x${'e'.repeat(40)}`; + const sppPlugin = generateDaoPlugin({ + address: `0x${'a'.repeat(40)}`, + settings: generateSppPluginSettings({ + stages: [], + externalProposers: [ + { + address, + proposalCreationConditionAddress: `0x${'c'.repeat(40)}`, + }, + ], + }), }); - mockSimulation({ isLoading: false, isSuccess: true }); - const { result } = renderHook(() => - useSppPermissionCheckProposalCreation( - params as Parameters< - typeof useSppPermissionCheckProposalCreation - >[0], - ), + // External proposers are not DAO plugins, so no meta matches by address. + useDaoPluginsSpy.mockReturnValue([]); + useDaoSpy.mockReturnValue( + generateReactQueryResultSuccess({ data: generateDao() }), ); + return { address, params: { daoId: 'dao-test', plugin: sppPlugin } }; + }; + + it.each([ + { + name: 'surfaces a Safe body settings group when it can create proposals', + createFixture: createStageBodySafeFixture, + }, + { + name: 'surfaces an external proposer Safe in the eligibility settings', + createFixture: createExternalProposerSafeFixture, + }, + ])('$name', ({ createFixture }) => { + const { address, params } = createFixture(); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); + + const { result } = renderGuard(params); + expect(result.current.isRestricted).toBeTruthy(); expect(result.current.settings).toEqual([ - [ - { - term: 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.pluginLabelName', - definition: addressUtils.truncateAddress(safeAddress), - link: { - href: `https://etherscan.io/address/${safeAddress}`, - isExternal: true, - }, - }, - { - term: 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.function', - definition: - 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.requirement', - }, - ], + buildExpectedSafeGroup(address), ]); }); @@ -291,15 +305,9 @@ describe('useSppPermissionCheckProposalCreation', () => { const params = createSafeTestParams({ proposalCreationConditionAddress: undefined, }); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); - const { result } = renderHook(() => - useSppPermissionCheckProposalCreation( - params as Parameters< - typeof useSppPermissionCheckProposalCreation - >[0], - ), - ); + const { result } = renderGuard(params); expect(result.current.isRestricted).toBeFalsy(); expect(result.current.settings).toEqual([]); @@ -310,34 +318,19 @@ describe('useSppPermissionCheckProposalCreation', () => { brandId: VotingBodyBrandIdentity.EOA, proposalCreationConditionAddress: `0x${'c'.repeat(40)}`, }); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); - const { result } = renderHook(() => - useSppPermissionCheckProposalCreation( - params as Parameters< - typeof useSppPermissionCheckProposalCreation - >[0], - ), - ); + const { result } = renderGuard(params); expect(result.current.isRestricted).toBeFalsy(); expect(result.current.settings).toEqual([]); }); - it('combines an installed internal body with an external Safe body in the same process', () => { - const internalAddress = `0x${'1'.repeat(40)}`; - const safeAddress = `0x${'2'.repeat(40)}`; - - const internalMeta = generateDaoPlugin({ address: internalAddress }); - const internalSettings = [ - [{ term: 'Members', definition: 'Listed only' }], - ]; - const internalGuardResult = generateGuardResult({ - isRestricted: true, - settings: internalSettings, - }); - - const sppPlugin = generateDaoPlugin({ + const buildStageBodySafeSppPlugin = ( + internalAddress: string, + safeAddress: string, + ) => + generateDaoPlugin({ address: `0x${'a'.repeat(40)}`, settings: generateSppPluginSettings({ stages: [ @@ -358,109 +351,44 @@ describe('useSppPermissionCheckProposalCreation', () => { }), }); - useDaoPluginsSpy.mockReturnValue([ - generateFilterComponentPlugin({ meta: internalMeta }), - ]); - useDaoSpy.mockReturnValue( - generateReactQueryResultSuccess({ data: generateDao() }), - ); - - // Only the internal body resolves to a slot function; the external Safe body - // (pluginId 'external') falls through to the fallback hook. - getSlotFunctionSpy.mockImplementation(((slotParams: { - pluginId: string; - }) => - slotParams.pluginId === internalMeta.interfaceType - ? () => internalGuardResult - : undefined) as never); - mockSimulation({ isLoading: false, isSuccess: true }); - - const params = { daoId: 'dao-test', plugin: sppPlugin }; - const { result } = renderHook(() => - useSppPermissionCheckProposalCreation( - params as Parameters< - typeof useSppPermissionCheckProposalCreation - >[0], - ), - ); - - expect(result.current.isRestricted).toBeTruthy(); - expect(result.current.settings).toEqual([ - ...internalSettings, - [ - { - term: 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.pluginLabelName', - definition: addressUtils.truncateAddress(safeAddress), - link: { - href: `https://etherscan.io/address/${safeAddress}`, - isExternal: true, - }, - }, - { - term: 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.function', - definition: - 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.requirement', - }, - ], - ]); - }); - - it('surfaces an external proposer Safe in the eligibility settings', () => { - const externalProposerAddress = `0x${'e'.repeat(40)}`; - const sppPlugin = generateDaoPlugin({ + const buildExternalProposerSafeSppPlugin = ( + internalAddress: string, + safeAddress: string, + ) => + generateDaoPlugin({ address: `0x${'a'.repeat(40)}`, settings: generateSppPluginSettings({ - stages: [], + stages: [ + generateSppStage({ + plugins: [ + generateSppStagePlugin({ + address: internalAddress, + }), + ], + }), + ], externalProposers: [ { - address: externalProposerAddress, + address: safeAddress, proposalCreationConditionAddress: `0x${'c'.repeat(40)}`, }, ], }), }); - // External proposers are not DAO plugins, so no meta matches by address. - useDaoPluginsSpy.mockReturnValue([]); - useDaoSpy.mockReturnValue( - generateReactQueryResultSuccess({ data: generateDao() }), - ); - mockSimulation({ isLoading: false, isSuccess: true }); - - const params = { daoId: 'dao-test', plugin: sppPlugin }; - const { result } = renderHook(() => - useSppPermissionCheckProposalCreation( - params as Parameters< - typeof useSppPermissionCheckProposalCreation - >[0], - ), - ); - - expect(result.current.isRestricted).toBeTruthy(); - expect(result.current.settings).toEqual([ - [ - { - term: 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.pluginLabelName', - definition: addressUtils.truncateAddress( - externalProposerAddress, - ), - link: { - href: `https://etherscan.io/address/${externalProposerAddress}`, - isExternal: true, - }, - }, - { - term: 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.function', - definition: - 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.requirement', - }, - ], - ]); - }); - - it('appends external proposer Safe groups after stage-body groups', () => { + it.each([ + { + name: 'combines an installed internal body with an external Safe body in the same process', + safeAddress: `0x${'2'.repeat(40)}`, + buildSppPlugin: buildStageBodySafeSppPlugin, + }, + { + name: 'appends external proposer Safe groups after stage-body groups', + safeAddress: `0x${'e'.repeat(40)}`, + buildSppPlugin: buildExternalProposerSafeSppPlugin, + }, + ])('$name', ({ safeAddress, buildSppPlugin }) => { const internalAddress = `0x${'1'.repeat(40)}`; - const externalProposerAddress = `0x${'e'.repeat(40)}`; const internalMeta = generateDaoPlugin({ address: internalAddress }); const internalSettings = [ @@ -471,26 +399,7 @@ describe('useSppPermissionCheckProposalCreation', () => { settings: internalSettings, }); - const sppPlugin = generateDaoPlugin({ - address: `0x${'a'.repeat(40)}`, - settings: generateSppPluginSettings({ - stages: [ - generateSppStage({ - plugins: [ - generateSppStagePlugin({ - address: internalAddress, - }), - ], - }), - ], - externalProposers: [ - { - address: externalProposerAddress, - proposalCreationConditionAddress: `0x${'c'.repeat(40)}`, - }, - ], - }), - }); + const sppPlugin = buildSppPlugin(internalAddress, safeAddress); useDaoPluginsSpy.mockReturnValue([ generateFilterComponentPlugin({ meta: internalMeta }), @@ -498,45 +407,25 @@ describe('useSppPermissionCheckProposalCreation', () => { useDaoSpy.mockReturnValue( generateReactQueryResultSuccess({ data: generateDao() }), ); - // Only the internal body resolves to a slot function; the external proposer Safe - // (pluginId 'external') falls through to the fallback hook. + + // Only the internal body resolves to a slot function; the external Safe + // (stage body or external proposer) falls through to the fallback hook + // (pluginId 'external'). getSlotFunctionSpy.mockImplementation(((slotParams: { pluginId: string; }) => slotParams.pluginId === internalMeta.interfaceType ? () => internalGuardResult : undefined) as never); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); const params = { daoId: 'dao-test', plugin: sppPlugin }; - const { result } = renderHook(() => - useSppPermissionCheckProposalCreation( - params as Parameters< - typeof useSppPermissionCheckProposalCreation - >[0], - ), - ); + const { result } = renderGuard(params); expect(result.current.isRestricted).toBeTruthy(); expect(result.current.settings).toEqual([ ...internalSettings, - [ - { - term: 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.pluginLabelName', - definition: addressUtils.truncateAddress( - externalProposerAddress, - ), - link: { - href: `https://etherscan.io/address/${externalProposerAddress}`, - isExternal: true, - }, - }, - { - term: 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.function', - definition: - 'app.plugins.spp.sppExternalPermissionCheckProposalCreation.requirement', - }, - ], + buildExpectedSafeGroup(safeAddress), ]); }); }); diff --git a/apps/app/src/plugins/sppPlugin/types/enum/sppVotingBodyBrandIdentity.ts b/apps/app/src/plugins/sppPlugin/types/enum/sppVotingBodyBrandIdentity.ts index ddcab9ec39..2a8b930fb3 100644 --- a/apps/app/src/plugins/sppPlugin/types/enum/sppVotingBodyBrandIdentity.ts +++ b/apps/app/src/plugins/sppPlugin/types/enum/sppVotingBodyBrandIdentity.ts @@ -1,5 +1 @@ -export enum VotingBodyBrandIdentity { - EOA = 'eoa', - SAFE = 'safe', - OTHER = 'other', -} +export { PermissionEntityBrandId as VotingBodyBrandIdentity } from '@/shared/api/daoService'; diff --git a/apps/app/src/shared/api/daoService/domain/daoPermission.ts b/apps/app/src/shared/api/daoService/domain/daoPermission.ts index f988b20dd1..3dd1ec2f36 100644 --- a/apps/app/src/shared/api/daoService/domain/daoPermission.ts +++ b/apps/app/src/shared/api/daoService/domain/daoPermission.ts @@ -1,6 +1,55 @@ +import type { + Network, + PermissionEntityBrandId, + PermissionEntityLayer, + PermissionEntityRole, + PermissionEntityStatus, +} from './enum'; + +export interface IDaoPermissionCondition { + /** + * Backend condition discriminator, e.g. `voting-power`, `membership`, + * `execute-selector`, or `unknown`. + */ + conditionType: string; + token?: string; + minVotingPower?: string; + onlyListed?: boolean; + minApprovals?: number; + /** Untrusted backend payload, narrowed by the execute-selector slot. */ + selectors?: unknown; + /** Untrusted backend payload, narrowed by the execute-selector slot. */ + targets?: unknown; +} + +export interface IPermissionEntityRef { + address: string; + layer: PermissionEntityLayer; + label?: string; + interfaceType?: string; + status?: PermissionEntityStatus; + parentPluginAddress?: string; + parentPluginName?: string; + parentInterfaceType?: string; + stageIndex?: number; + role?: PermissionEntityRole; + avatarSrc?: string; + /** + * Governance body brand identity, mirrored from the backend permission + * entity enrichment (see app-backend #1491). `safe` marks a Safe-based + * process body or external proposer. + */ + brandId?: PermissionEntityBrandId; + /** + * Address of the proposal-creation condition wired to a Safe body, when the + * backend can resolve it. + */ + proposalCreationConditionAddress?: string; +} + export interface IDaoPermission { /** - * Pemission ID. keccak256 hash of a permission string. + * Permission ID. keccak256 hash of a permission string. */ permissionId: string; /** @@ -17,4 +66,24 @@ export interface IDaoPermission { * `IPermissionCondition` contract implementation to be used. */ conditionAddress: string; + /** + * Enriched condition details returned by the backend when available. + */ + condition?: IDaoPermissionCondition; + /** + * Backend-enriched display metadata for the permission actor. + */ + who?: IPermissionEntityRef; + /** + * Backend-enriched display metadata for the permission target. + */ + where?: IPermissionEntityRef; + /** + * Backend-enriched display metadata for the permission condition contract. + */ + conditionEntity?: IPermissionEntityRef; + /** + * Network of the DAO permission event. + */ + network?: Network; } diff --git a/apps/app/src/shared/api/daoService/domain/daoPlugin.ts b/apps/app/src/shared/api/daoService/domain/daoPlugin.ts index 8c77537de3..d57032b2f2 100644 --- a/apps/app/src/shared/api/daoService/domain/daoPlugin.ts +++ b/apps/app/src/shared/api/daoService/domain/daoPlugin.ts @@ -2,6 +2,17 @@ import type { PluginInterfaceType } from './enum'; import type { IPluginSettings } from './pluginSettings'; import type { IResource } from './resource'; +export interface IDaoSubPlugin { + /** + * Addresses of the sub / child plugins used by a parent plugin. + */ + addresses: string[]; + /** + * Stage index where this subplugin group is configured, when applicable. + */ + stageIndex?: number; +} + export interface IDaoPlugin< TSettings extends IPluginSettings = IPluginSettings, > { @@ -66,6 +77,10 @@ export interface IDaoPlugin< * Address of the parent plugin's smart contract. */ parentPlugin?: string; + /** + * Sub / child plugin addresses configured by this plugin. + */ + subPlugins?: IDaoSubPlugin[]; /** * Block timestamp when the plugin was created. */ diff --git a/apps/app/src/shared/api/daoService/domain/enum/index.ts b/apps/app/src/shared/api/daoService/domain/enum/index.ts index 8ec32fd5b9..66678fcc71 100644 --- a/apps/app/src/shared/api/daoService/domain/enum/index.ts +++ b/apps/app/src/shared/api/daoService/domain/enum/index.ts @@ -1,3 +1,9 @@ export { Network } from './network'; +export type { + PermissionEntityLayer, + PermissionEntityRole, + PermissionEntityStatus, +} from './permissionEntity'; +export { PermissionEntityBrandId } from './permissionEntity'; export { PluginContractName } from './pluginContractName'; export { PluginInterfaceType } from './pluginInterfaceType'; diff --git a/apps/app/src/shared/api/daoService/domain/enum/permissionEntity.ts b/apps/app/src/shared/api/daoService/domain/enum/permissionEntity.ts new file mode 100644 index 0000000000..28113b339c --- /dev/null +++ b/apps/app/src/shared/api/daoService/domain/enum/permissionEntity.ts @@ -0,0 +1,26 @@ +export const PermissionEntityBrandId = { + EOA: 'eoa', + SAFE: 'safe', + OTHER: 'other', +} as const; + +export type PermissionEntityBrandId = + (typeof PermissionEntityBrandId)[keyof typeof PermissionEntityBrandId]; + +export type PermissionEntityLayer = + | 'dao' + | 'topLevelPlugin' + | 'processInternal' + | 'condition' + | 'externalActor' + | 'historicalPlugin' + | 'contract' + | 'unknown'; + +export type PermissionEntityRole = 'who' | 'where' | 'condition'; + +export type PermissionEntityStatus = + | 'installed' + | 'uninstalled' + | 'historical' + | 'unknown'; diff --git a/apps/app/src/shared/api/daoService/domain/index.ts b/apps/app/src/shared/api/daoService/domain/index.ts index b8cf0f3ac1..429c0bd84e 100644 --- a/apps/app/src/shared/api/daoService/domain/index.ts +++ b/apps/app/src/shared/api/daoService/domain/index.ts @@ -1,7 +1,11 @@ export type { IAddressInfo } from './addressInfo'; export type { IDao, ILinkedAccountSummary } from './dao'; export type { IDaoMetrics } from './daoMetrics'; -export type { IDaoPermission } from './daoPermission'; +export type { + IDaoPermission, + IDaoPermissionCondition, + IPermissionEntityRef, +} from './daoPermission'; export type { IDaoPlugin } from './daoPlugin'; export { type IDaoPolicy, @@ -10,6 +14,15 @@ export { PolicyStrategySourceType, PolicyStrategyType, } from './daoPolicy'; -export { Network, PluginInterfaceType } from './enum'; +export type { + PermissionEntityLayer, + PermissionEntityRole, + PermissionEntityStatus, +} from './enum'; +export { + Network, + PermissionEntityBrandId, + PluginInterfaceType, +} from './enum'; export type { IPluginSettings } from './pluginSettings'; export type { IResource } from './resource'; diff --git a/apps/app/src/shared/testUtils/generators/daoPermission.test.ts b/apps/app/src/shared/testUtils/generators/daoPermission.test.ts new file mode 100644 index 0000000000..11e8d9520c --- /dev/null +++ b/apps/app/src/shared/testUtils/generators/daoPermission.test.ts @@ -0,0 +1,55 @@ +import { Network } from '@/shared/api/daoService'; +import { + generateDaoPermission, + generatePermissionEntityRef, +} from './daoPermission'; + +describe('dao permission generators', () => { + it('builds coherent enriched defaults and applies overrides', () => { + const permission = generateDaoPermission(); + + expect(permission).toEqual({ + condition: { conditionType: 'unknown' }, + conditionAddress: '0x3333333333333333333333333333333333333333', + conditionEntity: { + address: '0x3333333333333333333333333333333333333333', + layer: 'condition', + }, + network: Network.ETHEREUM_MAINNET, + permissionId: '0xPermissionId', + where: { + address: '0x2222222222222222222222222222222222222222', + layer: 'unknown', + }, + whereAddress: '0x2222222222222222222222222222222222222222', + who: { + address: '0x1111111111111111111111111111111111111111', + layer: 'unknown', + }, + whoAddress: '0x1111111111111111111111111111111111111111', + }); + + const who = generatePermissionEntityRef({ + address: '0x1111111111111111111111111111111111111111', + label: 'Treasury Safe', + layer: 'externalActor', + }); + const overridden = generateDaoPermission({ + condition: { + conditionType: 'voting-power', + minVotingPower: '1000000000000000000', + }, + network: Network.ETHEREUM_MAINNET, + who, + }); + + expect(overridden).toMatchObject({ + condition: { + conditionType: 'voting-power', + minVotingPower: '1000000000000000000', + }, + network: Network.ETHEREUM_MAINNET, + who, + }); + }); +}); diff --git a/apps/app/src/shared/testUtils/generators/daoPermission.ts b/apps/app/src/shared/testUtils/generators/daoPermission.ts index 2b037a4c52..79941160bb 100644 --- a/apps/app/src/shared/testUtils/generators/daoPermission.ts +++ b/apps/app/src/shared/testUtils/generators/daoPermission.ts @@ -1,11 +1,35 @@ -import type { IDaoPermission } from '@/shared/api/daoService'; +import { + type IDaoPermission, + type IPermissionEntityRef, + Network, +} from '@/shared/api/daoService'; + +const DEFAULT_WHO_ADDRESS = '0x1111111111111111111111111111111111111111'; +const DEFAULT_WHERE_ADDRESS = '0x2222222222222222222222222222222222222222'; +const DEFAULT_CONDITION_ADDRESS = '0x3333333333333333333333333333333333333333'; + +export const generatePermissionEntityRef = ( + entity?: Partial, +): IPermissionEntityRef => ({ + address: DEFAULT_WHO_ADDRESS, + layer: 'unknown', + ...entity, +}); export const generateDaoPermission = ( daoPermission?: Partial, ): IDaoPermission => ({ permissionId: '0xPermissionId', - whoAddress: '0xWhoAddress', - whereAddress: '0xWhereAddress', - conditionAddress: '0xConditionAddress', + whoAddress: DEFAULT_WHO_ADDRESS, + whereAddress: DEFAULT_WHERE_ADDRESS, + conditionAddress: DEFAULT_CONDITION_ADDRESS, + condition: { conditionType: 'unknown' }, + conditionEntity: generatePermissionEntityRef({ + address: DEFAULT_CONDITION_ADDRESS, + layer: 'condition', + }), + network: Network.ETHEREUM_MAINNET, + who: generatePermissionEntityRef({ address: DEFAULT_WHO_ADDRESS }), + where: generatePermissionEntityRef({ address: DEFAULT_WHERE_ADDRESS }), ...daoPermission, }); diff --git a/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts b/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts index 6b1e3256ee..48adbd22b7 100644 --- a/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts +++ b/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts @@ -92,4 +92,39 @@ describe('permissionNameUtils', () => { ); }); }); + + describe('getPermissionDisplayName', () => { + it.each([ + { + permissionName: 'SET_METADATA_PERMISSION', + expected: 'Set metadata', + }, + { + permissionName: 'EXECUTE_PERMISSION', + expected: 'Execute', + }, + { + permissionName: 'SWEEPER_ROLE', + expected: 'Sweeper', + }, + ])('formats $permissionName for graph display', ({ + permissionName, + expected, + }) => { + expect( + permissionNameUtils.getPermissionDisplayName( + permissionNameUtils.getPermissionId(permissionName), + ), + ).toEqual(expected); + }); + + it('keeps unknown permission hashes unchanged', () => { + const permissionId = + '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + expect( + permissionNameUtils.getPermissionDisplayName(permissionId), + ).toEqual('0x01234567…89abcdef'); + }); + }); }); diff --git a/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.ts b/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.ts index 05c232f338..7000c4d8ec 100644 --- a/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.ts +++ b/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.ts @@ -138,6 +138,29 @@ class PermissionNameUtils { return addressUtils.truncateHash(this.normaliseHash(permissionId)); }; + /** + * Converts a resolved raw permission name to compact title case for dense UI + * surfaces. Unknown hashes are returned unchanged. + */ + getPermissionDisplayName = (permissionId: string): string => { + const permissionName = this.getPermissionName(permissionId); + + if (!permissionName.includes('_')) { + return permissionName; + } + + const displayName = permissionName + .replace(/_(PERMISSION|ROLE)$/u, '') + .split('_') + .filter(Boolean) + .map((word) => word.toLowerCase()) + .join(' '); + + return displayName.length > 0 + ? displayName.charAt(0).toUpperCase() + displayName.slice(1) + : permissionName; + }; + /** * Returns the keccak256 permission-id hash for a raw permission name. Inverse * of {@link getPermissionName}; the {@link permissionNames} list is the single