diff --git a/assets/js/lib/test-utils/index.jsx b/assets/js/lib/test-utils/index.jsx index a427ea6bcc..1cb8f6ffc9 100644 --- a/assets/js/lib/test-utils/index.jsx +++ b/assets/js/lib/test-utils/index.jsx @@ -16,6 +16,9 @@ const middlewares = []; const mockStore = configureStore(middlewares); export const defaultInitialState = { + activityLog: { + users: [], + }, user: { abilities: [{ name: 'all', resource: 'all' }], }, diff --git a/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx b/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx index 7ed8b592e5..a27440f045 100644 --- a/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx +++ b/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx @@ -6,7 +6,7 @@ import { map, pipe } from 'lodash/fp'; import { getActivityLog } from '@lib/api/activityLogs'; import { allowedActivities } from '@lib/model/activityLog'; - +import { getActivityLogUsers } from '@state/selectors/activityLog'; import { getUserProfile } from '@state/selectors/user'; import PageHeader from '@common/PageHeader'; @@ -20,6 +20,7 @@ import { } from './searchParams'; function ActivityLogPage() { + const users = useSelector(getActivityLogUsers); const [searchParams, setSearchParams] = useSearchParams(); const [activityLog, setActivityLog] = useState([]); const [isLoading, setLoading] = useState(true); @@ -37,6 +38,12 @@ function ActivityLogPage() { map(([key, value]) => [key, value.label]) )(abilities), }, + { + key: 'actor', + type: 'select', + title: 'User', + options: users, + }, { key: 'to_date', title: 'newer than', diff --git a/assets/js/pages/ActivityLogPage/ActivityLogPage.test.jsx b/assets/js/pages/ActivityLogPage/ActivityLogPage.test.jsx index 60b1c1a849..b2e703c001 100644 --- a/assets/js/pages/ActivityLogPage/ActivityLogPage.test.jsx +++ b/assets/js/pages/ActivityLogPage/ActivityLogPage.test.jsx @@ -1,12 +1,19 @@ import React, { act } from 'react'; import { screen } from '@testing-library/react'; import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; import MockAdapter from 'axios-mock-adapter'; -import { renderWithRouter, withDefaultState } from '@lib/test-utils'; import { networkClient } from '@lib/network'; +import { + renderWithRouter, + withDefaultState, + withState, + defaultInitialState, +} from '@lib/test-utils'; import { activityLogEntryFactory } from '@lib/test-utils/factories/activityLog'; +import { userFactory } from '@lib/test-utils/factories/users'; import ActivityLogPage from './ActivityLogPage'; @@ -15,8 +22,8 @@ const axiosMock = new MockAdapter(networkClient); describe('ActivityLogPage', () => { it('should render table without data', async () => { axiosMock.onGet('/api/v1/activity_log').reply(200, { data: [] }); - const [StatefulActivityLogPage] = withDefaultState(); - await act(async () => renderWithRouter(StatefulActivityLogPage)); + const [StatefulActivityLogPage, _] = withDefaultState(); + await act(() => renderWithRouter(StatefulActivityLogPage)); expect(screen.getByText('No data available')).toBeVisible(); }); @@ -39,7 +46,9 @@ describe('ActivityLogPage', () => { axiosMock .onGet('/api/v1/activity_log') .reply(responseStatus, responseBody); - const [StatefulActivityLogPage] = withDefaultState(); + const [StatefulActivityLogPage, _] = withDefaultState( + + ); await act(() => renderWithRouter(StatefulActivityLogPage)); expect(screen.getByText('No data available')).toBeVisible(); @@ -50,13 +59,29 @@ describe('ActivityLogPage', () => { axiosMock .onGet('/api/v1/activity_log') .reply(200, { data: activityLogEntryFactory.buildList(5) }); + const [StatefulActivityLogPage, _] = withDefaultState(); + const { container } = await act(() => + renderWithRouter(StatefulActivityLogPage) + ); + expect(container.querySelectorAll('tbody > tr')).toHaveLength(5); + }); - const [StatefulActivityLogPage] = withDefaultState(); - + it('should render tracked activity log and the users filter with non-default/non-empty state', async () => { + const users = userFactory.buildList(5).map((user) => user.username); + axiosMock + .onGet('/api/v1/activity_log') + .reply(200, { data: activityLogEntryFactory.buildList(5) }); + const [StatefulActivityLogPage, _] = withState(, { + ...defaultInitialState, + activityLog: { users }, + }); const { container } = await act(() => renderWithRouter(StatefulActivityLogPage) ); - expect(container.querySelectorAll('tbody > tr')).toHaveLength(5); + await userEvent.click(screen.getByTestId('filter-User')); + expect(container.querySelectorAll('ul > li[role="option"]')).toHaveLength( + users.length + ); }); }); diff --git a/assets/js/state/activityLog.js b/assets/js/state/activityLog.js new file mode 100644 index 0000000000..b9da15352a --- /dev/null +++ b/assets/js/state/activityLog.js @@ -0,0 +1,21 @@ +import { createAction, createSlice } from '@reduxjs/toolkit'; + +export const initialState = { + users: [], +}; + +export const activityLogSlice = createSlice({ + name: 'activityLog', + initialState, + reducers: { + setUsers(state, { payload: { users } }) { + state.users = users; + }, + }, +}); + +export const ACTIVITY_LOG_USERS_PUSHED = 'ACTIVITY_LOG_USERS_PUSHED'; +export const activityLogUsersPushed = createAction(ACTIVITY_LOG_USERS_PUSHED); +export const { setUsers } = activityLogSlice.actions; + +export default activityLogSlice.reducer; diff --git a/assets/js/state/activityLog.test.js b/assets/js/state/activityLog.test.js new file mode 100644 index 0000000000..84bf130973 --- /dev/null +++ b/assets/js/state/activityLog.test.js @@ -0,0 +1,27 @@ +import { userFactory } from '@lib/test-utils/factories/users'; + +import activityLogReducer, { setUsers, initialState } from './activityLog'; + +describe('activityLog reducer', () => { + it('should set the users for activity log when setUsers is dispatched', () => { + const { username: username1 } = userFactory.build(); + const { username: username2 } = userFactory.build(); + const users = [username1, username2]; + const action = setUsers({ users }); + expect(activityLogReducer(initialState, action)).toEqual({ + users, + }); + }); + + it('should set the users for activity log when setUsers is dispatched with non empty initial state', () => { + const { username: username1 } = userFactory.build(); + const { username: username2 } = userFactory.build(); + const { username: username3 } = userFactory.build(); + const users = [username1, username2]; + const nonEmptyInitialState = { users: [username3] }; + const action = setUsers({ users }); + expect(activityLogReducer(nonEmptyInitialState, action)).toEqual({ + users, + }); + }); +}); diff --git a/assets/js/state/index.js b/assets/js/state/index.js index b2a16edca7..5da328ce03 100644 --- a/assets/js/state/index.js +++ b/assets/js/state/index.js @@ -14,6 +14,7 @@ import settingsReducer from './settings'; import userReducer from './user'; import softwareUpdatesReducer from './softwareUpdates'; import activityLogsSettingsReducer from './activityLogsSettings'; +import activityLogReducer from './activityLog'; import rootSaga from './sagas'; export const createStore = (router) => { @@ -38,6 +39,7 @@ export const createStore = (router) => { user: userReducer, softwareUpdates: softwareUpdatesReducer, activityLogsSettings: activityLogsSettingsReducer, + activityLog: activityLogReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(sagaMiddleware), diff --git a/assets/js/state/sagas/activityLog.js b/assets/js/state/sagas/activityLog.js new file mode 100644 index 0000000000..83cb159823 --- /dev/null +++ b/assets/js/state/sagas/activityLog.js @@ -0,0 +1,10 @@ +import { put, takeEvery } from 'redux-saga/effects'; +import { setUsers, ACTIVITY_LOG_USERS_PUSHED } from '@state/activityLog'; + +export function* activityLogUsersUpdate({ payload: { users } }) { + yield put(setUsers({ users })); +} + +export function* watchActivityLogActions() { + yield takeEvery(ACTIVITY_LOG_USERS_PUSHED, activityLogUsersUpdate); +} diff --git a/assets/js/state/sagas/activityLog.test.js b/assets/js/state/sagas/activityLog.test.js new file mode 100644 index 0000000000..f1a0d77e98 --- /dev/null +++ b/assets/js/state/sagas/activityLog.test.js @@ -0,0 +1,16 @@ +import { recordSaga } from '@lib/test-utils'; +import { userFactory } from '@lib/test-utils/factories/users'; +import { setUsers } from '@state/activityLog'; +import { activityLogUsersUpdate } from './activityLog'; + +describe('Activity Logs saga', () => { + it('should set users when activity log users are updated', async () => { + const users = userFactory.buildList(5).map((user) => user.username); + + const dispatched = await recordSaga(activityLogUsersUpdate, { + payload: { users }, + }); + + expect(dispatched).toEqual([setUsers({ users })]); + }); +}); diff --git a/assets/js/state/sagas/channels.js b/assets/js/state/sagas/channels.js index 5ec791be63..c0e07511f3 100644 --- a/assets/js/state/sagas/channels.js +++ b/assets/js/state/sagas/channels.js @@ -55,6 +55,7 @@ import { } from '@state/lastExecutions'; import { userUpdated, userLocked, userDeleted } from '@state/user'; +import { activityLogUsersPushed } from '@state/activityLog'; import { getUserProfile } from '@state/selectors/user'; @@ -236,6 +237,13 @@ const userEvents = [ }, ]; +const activityLogEvents = [ + { + name: 'al_users_pushed', + action: activityLogUsersPushed, + }, +]; + const createEventChannel = (channel, events) => eventChannel((emitter) => { events.forEach((event) => { @@ -281,5 +289,11 @@ export function* watchSocketEvents(socket) { fork(watchChannelEvents, socket, 'monitoring:databases', databaseEvents), fork(watchChannelEvents, socket, 'monitoring:executions', executionEvents), fork(watchChannelEvents, socket, `users:${userID}`, userEvents), + fork( + watchChannelEvents, + socket, + `activity_log:${userID}`, + activityLogEvents + ), ]); } diff --git a/assets/js/state/sagas/channels.test.js b/assets/js/state/sagas/channels.test.js index b6bf070cdf..58f81867ff 100644 --- a/assets/js/state/sagas/channels.test.js +++ b/assets/js/state/sagas/channels.test.js @@ -6,6 +6,7 @@ import { setExecutionStarted, } from '@state/lastExecutions'; import { userUpdated } from '@state/user'; +import { activityLogUsersPushed } from '@state/activityLog'; import { watchSocketEvents } from './channels'; @@ -48,6 +49,7 @@ const channels = { 'monitoring:databases': new MockChannel(), 'monitoring:executions': new MockChannel(), [`users:${USER_ID}`]: new MockChannel(), + [`activity_log:${USER_ID}`]: new MockChannel(), }; const mockSocket = { @@ -125,4 +127,18 @@ describe('Channels saga', () => { expect(dispatched).toEqual([userUpdated({ email: 'new@email.com' })]); }); + it('should listen to specific activity log events', async () => { + const { saga, dispatched } = runWatchSocketEventsSaga(mockSocket); + + channels[`activity_log:${USER_ID}`].emit('al_users_pushed', { + users: ['user1', 'user2', 'user3'], + }); + + closeSocket(); + await saga; + + expect(dispatched).toEqual([ + activityLogUsersPushed({ users: ['user1', 'user2', 'user3'] }), + ]); + }); }); diff --git a/assets/js/state/sagas/index.js b/assets/js/state/sagas/index.js index 1e3dae42d1..de70adf3c1 100644 --- a/assets/js/state/sagas/index.js +++ b/assets/js/state/sagas/index.js @@ -80,6 +80,7 @@ import { watchActivityLogsSettings } from '@state/sagas/activityLogsSettings'; import { watchSoftwareUpdates } from '@state/sagas/softwareUpdates'; import { watchSocketEvents } from '@state/sagas/channels'; +import { watchActivityLogActions } from '@state/sagas/activityLog'; import { checkApiKeyExpiration } from '@state/sagas/settings'; const RESET_STATE = 'RESET_STATE'; @@ -249,5 +250,6 @@ export default function* rootSaga() { watchUserLoggedIn(), watchActivityLogsSettings(), watchSoftwareUpdates(), + watchActivityLogActions(), ]); } diff --git a/assets/js/state/selectors/activityLog.js b/assets/js/state/selectors/activityLog.js new file mode 100644 index 0000000000..6c28a68be3 --- /dev/null +++ b/assets/js/state/selectors/activityLog.js @@ -0,0 +1,6 @@ +import { createSelector } from '@reduxjs/toolkit'; + +export const getActivityLogUsers = createSelector( + [(state) => state.activityLog], + (activityLog) => activityLog.users +); diff --git a/assets/js/state/selectors/activityLog.test.js b/assets/js/state/selectors/activityLog.test.js new file mode 100644 index 0000000000..e01d04c941 --- /dev/null +++ b/assets/js/state/selectors/activityLog.test.js @@ -0,0 +1,12 @@ +import { getActivityLogUsers } from './activityLog'; + +describe('Activity Log users selector', () => { + it('should return a list of users from activity log state', () => { + const users = ['user1', 'user2', 'user3']; + const state = { + activityLog: { users }, + }; + + expect(getActivityLogUsers(state)).toEqual(users); + }); +}); diff --git a/config/config.exs b/config/config.exs index abbea1856f..a526aa6efe 100644 --- a/config/config.exs +++ b/config/config.exs @@ -221,6 +221,8 @@ config :flop, repo: Trento.Repo config :trento, admin_user: "admin" +config :trento, :activity_log, refresh_interval: 60_000 + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/test.exs b/config/test.exs index e780cd81e7..e8aaff9925 100644 --- a/config/test.exs +++ b/config/test.exs @@ -91,3 +91,5 @@ config :trento, Trento.Infrastructure.SoftwareUpdates.MockSuma, # 448 matches to "test" fqdn 448 ] + +config :trento, :activity_log, refresh_interval: 1 diff --git a/lib/trento/users.ex b/lib/trento/users.ex index bb12353c4b..a641c14182 100644 --- a/lib/trento/users.ex +++ b/lib/trento/users.ex @@ -37,6 +37,16 @@ defmodule Trento.Users do |> Repo.all() end + @doc """ + Returns all usernames tupled with the deleted_at timestamp, including those for users that are soft-deleted. + """ + @spec list_all_usernames :: list({String.t(), DateTime.t()}) + def list_all_usernames do + User + |> select([u], {u.username, u.deleted_at}) + |> Repo.all() + end + def get_user(id) do case User |> where([u], is_nil(u.deleted_at) and u.id == ^id) diff --git a/lib/trento_web/channels/activity_log_channel.ex b/lib/trento_web/channels/activity_log_channel.ex new file mode 100644 index 0000000000..64fa177e28 --- /dev/null +++ b/lib/trento_web/channels/activity_log_channel.ex @@ -0,0 +1,57 @@ +defmodule TrentoWeb.ActivityLogChannel do + @moduledoc """ + Activity Log channel, each user is subscribed to this channel, + + """ + require Logger + use TrentoWeb, :channel + alias Trento.Users + @refresh_interval Application.compile_env!(:trento, [:activity_log, :refresh_interval]) + + @impl true + def join( + "activity_log:" <> user_id, + _payload, + %{assigns: %{current_user_id: current_user_id}} = socket + ) do + if allowed?(user_id, current_user_id) do + send(self(), :after_join) + {:ok, socket} + else + Logger.error( + "Could not join activity_log channel, requested user id: #{user_id}, authenticated user id: #{current_user_id}" + ) + + {:error, :unauthorized} + end + end + + def join("activity_log:" <> _user_id, _payload, _socket) do + {:error, :user_not_logged} + end + + @impl true + def handle_info(:after_join, socket) do + all_usernames_ts = Users.list_all_usernames() + collapsed_usernames = collapse_usernames(all_usernames_ts) + + users = ["system" | collapsed_usernames] + push(socket, "al_users_pushed", %{users: users}) + Process.send_after(self(), :after_join, @refresh_interval) + {:noreply, socket} + end + + defp allowed?(user_id, current_user_id), do: String.to_integer(user_id) == current_user_id + + defp collapse_usernames(usernames_ts) do + usernames_ts + |> Enum.map(fn + {username, nil} -> + username + + {username, deleted_at} -> + String.trim_trailing(username, "__" <> DateTime.to_string(deleted_at)) + end) + |> Enum.uniq() + end +end diff --git a/lib/trento_web/channels/user_socket.ex b/lib/trento_web/channels/user_socket.ex index 7e20cdb649..fbebeb6b43 100644 --- a/lib/trento_web/channels/user_socket.ex +++ b/lib/trento_web/channels/user_socket.ex @@ -13,6 +13,7 @@ defmodule TrentoWeb.UserSocket do channel "monitoring:*", TrentoWeb.MonitoringChannel channel "users:*", TrentoWeb.UserChannel + channel "activity_log:*", TrentoWeb.ActivityLogChannel # Socket params are passed from the client and can # be used to verify and authenticate a user. After diff --git a/test/trento/users_test.exs b/test/trento/users_test.exs index 7f1f69ab78..40494a0ef9 100644 --- a/test/trento/users_test.exs +++ b/test/trento/users_test.exs @@ -117,6 +117,25 @@ defmodule Trento.UsersTest do assert length(users) == 1 end + test "list_all_usernames returns all usernames tupled with the deleted_at field, including those for deleted users" do + %{username: username1, deleted_at: deleted_at1} = insert(:user) + + %{username: username2, deleted_at: deleted_at2} = + insert(:user, deleted_at: DateTime.utc_now()) + + sorter_fn = fn {username, _deleted_at} -> username end + + inserted_sorted_usernames = + Enum.sort_by([{username1, deleted_at1}, {username2, deleted_at2}], sorter_fn) + + sorted_usernames = + Enum.sort_by(Users.list_all_usernames(), sorter_fn) + + assert inserted_sorted_usernames == sorted_usernames + assert length(sorted_usernames) == 2 + assert length(inserted_sorted_usernames) == 2 + end + test "get_user returns a user when the user exist" do %{id: user_id} = insert(:user) diff --git a/test/trento_web/channels/activity_log_channel_test.exs b/test/trento_web/channels/activity_log_channel_test.exs new file mode 100644 index 0000000000..ad1398d6bd --- /dev/null +++ b/test/trento_web/channels/activity_log_channel_test.exs @@ -0,0 +1,28 @@ +defmodule TrentoWeb.ActivityLogChannelTest do + use TrentoWeb.ChannelCase + + test "Socket users can only join the activity log channel" do + assert {:ok, _, _socket} = + TrentoWeb.UserSocket + |> socket("user_id", %{current_user_id: 876}) + |> join(TrentoWeb.ActivityLogChannel, "activity_log:876") + + assert_push("al_users_pushed", %{users: _}) + assert_push("al_users_pushed", %{users: _}) + assert_push("al_users_pushed", %{users: _}) + end + + test "Unauthorized users cannot join the activity log channel" do + assert {:error, :unauthorized} = + TrentoWeb.UserSocket + |> socket("user_id", %{current_user_id: 788}) + |> join(TrentoWeb.ActivityLogChannel, "activity_log:876") + end + + test "Non logged users cannot join an activity log channel" do + assert {:error, :user_not_logged} = + TrentoWeb.UserSocket + |> socket("user_id", %{}) + |> join(TrentoWeb.ActivityLogChannel, "activity_log:8989") + end +end