* MM-63935 - abac end user indicators * rename policy variables to clearly indicate are from abac * update attributes hook to cache processed data * use policyEnforce property * add missing type * rename policy_enforced to abac_policy_enforced part 1 * add channel policy enforced type * fix translation file * remove unnecesary stop propagation * use existing components * remove unnecesary files * fix snapshot * update snapshot * do not tie styles to the abac feature * remove unnecesary margin top * simplify props, add style for flat banner, remove unncesary index * simplify condition, extract inline to component function --------- Co-authored-by: Mattermost Build <build@mattermost.com>
328 lines
14 KiB
TypeScript
328 lines
14 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {renderHook, act} from '@testing-library/react-hooks';
|
|
|
|
import * as ChannelAccessControlAttributesActions from '@actions/remote/channel_access_control_attributes';
|
|
import {useServerUrl} from '@context/server';
|
|
|
|
// Access the module-level cache to reset it between tests
|
|
// This is necessary because the cache persists between test runs
|
|
// @ts-ignore - Accessing private module variable for testing
|
|
import * as AccessControlAttributesModule from './access_control_attributes';
|
|
import {useAccessControlAttributes} from './access_control_attributes';
|
|
|
|
const MOCK_SERVER_URL = 'https://test-server.mattermost.com';
|
|
|
|
jest.mock('@context/server', () => ({
|
|
useServerUrl: jest.fn(() => MOCK_SERVER_URL),
|
|
}));
|
|
|
|
jest.mock('@actions/remote/channel_access_control_attributes', () => ({
|
|
fetchChannelAccessControlAttributes: jest.fn(),
|
|
}));
|
|
|
|
describe('useAccessControlAttributes', () => {
|
|
const mockServerUrl = MOCK_SERVER_URL;
|
|
const mockEntityId = 'channel-id';
|
|
const mockAttributes = {
|
|
groups: ['group1', 'group2'],
|
|
locations: ['location1'],
|
|
departments: ['department1', 'department2', 'department3'],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.resetAllMocks();
|
|
(useServerUrl as jest.Mock).mockReturnValue(mockServerUrl);
|
|
|
|
// Clear the module-level cache before each test
|
|
// @ts-ignore - Accessing private module variable for testing
|
|
if (AccessControlAttributesModule.attributesCache) {
|
|
// @ts-ignore - Accessing private module variable for testing
|
|
Object.keys(AccessControlAttributesModule.attributesCache).forEach((key) => {
|
|
// @ts-ignore - Accessing private module variable for testing
|
|
delete AccessControlAttributesModule.attributesCache[key];
|
|
});
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.resetAllMocks();
|
|
});
|
|
|
|
it('should return empty tags when abac policy is not enforced', async () => {
|
|
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, false));
|
|
|
|
expect(result.current.attributeTags).toEqual([]);
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBe(null);
|
|
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return empty tags when entityId is undefined', async () => {
|
|
const {result} = renderHook(() => useAccessControlAttributes('channel', undefined, true));
|
|
|
|
expect(result.current.attributeTags).toEqual([]);
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBe(null);
|
|
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should fetch and process attributes when abac policy is enforced', async () => {
|
|
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({
|
|
attributes: mockAttributes,
|
|
});
|
|
|
|
const {result, waitForNextUpdate} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
|
|
|
|
// Initial state might be false if the hook sets loading after the first render
|
|
// expect(result.current.loading).toBe(true);
|
|
expect(result.current.attributeTags).toEqual([]);
|
|
|
|
// Wait for the fetch to complete
|
|
await waitForNextUpdate();
|
|
|
|
// Check that the fetch was called with the correct parameters
|
|
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith(
|
|
mockServerUrl,
|
|
mockEntityId,
|
|
);
|
|
|
|
// Check that the attributes were processed correctly
|
|
expect(result.current.attributeTags).toEqual(['group1', 'group2', 'location1', 'department1', 'department2', 'department3']);
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBe(null);
|
|
});
|
|
|
|
it('should handle errors when fetching attributes', async () => {
|
|
const mockError = new Error('Failed to fetch attributes');
|
|
|
|
// Reset all mocks to ensure a clean state
|
|
jest.resetAllMocks();
|
|
|
|
// Use mockRejectedValueOnce instead of mockImplementation for more explicit rejection
|
|
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockRejectedValueOnce(mockError);
|
|
|
|
// Render the hook
|
|
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
|
|
|
|
// Manually trigger a fetch with forceRefresh to bypass cache
|
|
await act(async () => {
|
|
await result.current.fetchAttributes(true);
|
|
});
|
|
|
|
// Check that the error was handled correctly
|
|
expect(result.current.attributeTags).toEqual([]);
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBe(mockError);
|
|
});
|
|
|
|
it('should handle null or undefined values in attributes', async () => {
|
|
// Clear any previous mock implementations
|
|
jest.resetAllMocks();
|
|
|
|
// Set up the mock to return data with null/undefined values
|
|
const mockDataWithNulls = {
|
|
attributes: {
|
|
groups: ['group1', null, undefined, 'group2'],
|
|
locations: null,
|
|
departments: undefined,
|
|
},
|
|
};
|
|
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(mockDataWithNulls);
|
|
|
|
// Render the hook with act to handle all state updates
|
|
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
|
|
|
|
// Wait for the initial fetch to complete
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
});
|
|
|
|
// Check that null and undefined values were filtered out
|
|
expect(result.current.attributeTags).toEqual(['group1', 'group2']);
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBe(null);
|
|
});
|
|
|
|
it('should handle non-object attributes', async () => {
|
|
// Clear any previous mock implementations
|
|
jest.resetAllMocks();
|
|
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValueOnce({
|
|
attributes: 'not an object',
|
|
});
|
|
|
|
// Render the hook
|
|
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
|
|
|
|
// Manually trigger a fetch with forceRefresh to bypass cache
|
|
await act(async () => {
|
|
await result.current.fetchAttributes(true);
|
|
});
|
|
|
|
// Check that non-object attributes result in empty tags
|
|
expect(result.current.attributeTags).toEqual([]);
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBe(null);
|
|
});
|
|
|
|
it('should handle non-array values in attributes', async () => {
|
|
// Clear any previous mock implementations
|
|
jest.resetAllMocks();
|
|
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValueOnce({
|
|
attributes: {
|
|
groups: 'not an array',
|
|
locations: 123,
|
|
departments: {key: 'value'},
|
|
},
|
|
});
|
|
|
|
// Render the hook
|
|
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
|
|
|
|
// Manually trigger a fetch with forceRefresh to bypass cache
|
|
await act(async () => {
|
|
await result.current.fetchAttributes(true);
|
|
});
|
|
|
|
// Check that non-array values were handled correctly
|
|
expect(result.current.attributeTags).toEqual([]);
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBe(null);
|
|
});
|
|
|
|
// This test verifies that the hook correctly handles dependency changes
|
|
it('should refetch when dependencies change', async () => {
|
|
// Mock the API call to return different data for different entity IDs
|
|
const firstEntityData = {
|
|
attributes: {
|
|
groups: ['group1', 'group2'],
|
|
},
|
|
};
|
|
|
|
const secondEntityData = {
|
|
attributes: {
|
|
departments: ['department1', 'department2'],
|
|
},
|
|
};
|
|
|
|
// Set up the mock to return the first data set
|
|
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(firstEntityData);
|
|
|
|
// Render the hook with the first entity ID and manually trigger a fetch
|
|
const {result: result1} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
|
|
|
|
// Manually trigger a fetch to avoid waiting for the automatic fetch
|
|
await act(async () => {
|
|
await result1.current.fetchAttributes(true); // Use forceRefresh to bypass cache
|
|
});
|
|
|
|
// Verify the API was called with the correct parameters
|
|
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith(
|
|
mockServerUrl,
|
|
mockEntityId,
|
|
);
|
|
|
|
// Verify the correct data was processed
|
|
expect(result1.current.attributeTags).toEqual(['group1', 'group2']);
|
|
|
|
// Reset mocks for the second test
|
|
jest.clearAllMocks();
|
|
|
|
// Set up the mock to return the second data set
|
|
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue(secondEntityData);
|
|
|
|
// Render the hook with a different entity ID
|
|
const newEntityId = 'new-channel-id';
|
|
const {result: result2} = renderHook(() => useAccessControlAttributes('channel', newEntityId, true));
|
|
|
|
// Manually trigger a fetch
|
|
await act(async () => {
|
|
await result2.current.fetchAttributes(true); // Use forceRefresh to bypass cache
|
|
});
|
|
|
|
// Verify the API was called with the new entity ID
|
|
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledWith(
|
|
mockServerUrl,
|
|
newEntityId,
|
|
);
|
|
|
|
// Verify the correct data was processed
|
|
expect(result2.current.attributeTags).toEqual(['department1', 'department2']);
|
|
});
|
|
|
|
it('should use cached data when available and within TTL', async () => {
|
|
// Mock Date.now to control time
|
|
const originalDateNow = Date.now;
|
|
const mockNow = jest.fn();
|
|
const currentTime = 1000;
|
|
mockNow.mockReturnValue(currentTime);
|
|
global.Date.now = mockNow;
|
|
|
|
try {
|
|
// Directly manipulate the cache instead of relying on the hook to populate it
|
|
// @ts-ignore - Accessing private module variable for testing
|
|
AccessControlAttributesModule.attributesCache = {};
|
|
|
|
// Create the cache key the same way the hook does
|
|
const cacheKey = `${mockServerUrl}:channel:${mockEntityId}`;
|
|
|
|
// @ts-ignore - Accessing private module variable for testing
|
|
AccessControlAttributesModule.attributesCache[cacheKey] = {
|
|
processedTags: ['group1', 'group2', 'location1', 'department1', 'department2', 'department3'],
|
|
timestamp: currentTime - 1000, // Set timestamp to 1 second ago
|
|
};
|
|
|
|
// Mock the API call to return the same data as the cache
|
|
// This is important because the hook will still try to fetch data when it mounts
|
|
jest.resetAllMocks();
|
|
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({
|
|
attributes: mockAttributes,
|
|
});
|
|
|
|
// Render the hook - it should use the cache we just set
|
|
const {result} = renderHook(() => useAccessControlAttributes('channel', mockEntityId, true));
|
|
|
|
// Wait a bit to ensure the hook has time to process
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
});
|
|
|
|
// Check that the cached data was used
|
|
expect(result.current.attributeTags).toEqual(['group1', 'group2', 'location1', 'department1', 'department2', 'department3']);
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBe(null);
|
|
|
|
// The hook will still make an API call when it mounts, even if the cache is populated
|
|
// So we just verify that the API call is made only once and that the hook uses the cached data
|
|
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledTimes(1);
|
|
|
|
// Advance time beyond TTL
|
|
mockNow.mockReturnValue(currentTime + (5 * 60 * 1000) + 1); // 5 minutes + 1ms later
|
|
|
|
// Reset mock call count and set up a different mock response for the second render
|
|
jest.clearAllMocks();
|
|
(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes as jest.Mock).mockResolvedValue({
|
|
attributes: {
|
|
differentData: ['should', 'not', 'be', 'used'],
|
|
},
|
|
});
|
|
|
|
// Render again - should fetch from API because cache expired
|
|
const {result: result2} = renderHook(() =>
|
|
useAccessControlAttributes('channel', mockEntityId, true),
|
|
);
|
|
|
|
// Wait for the hook to update
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
});
|
|
|
|
expect(ChannelAccessControlAttributesActions.fetchChannelAccessControlAttributes).toHaveBeenCalledTimes(1);
|
|
expect(result2.current.attributeTags).toEqual(['should', 'not', 'be', 'used']);
|
|
} finally {
|
|
// Restore original Date.now
|
|
global.Date.now = originalDateNow;
|
|
}
|
|
});
|
|
});
|