diff --git a/src/actions/__tests__/audit-log-actions.test.js b/src/actions/__tests__/audit-log-actions.test.js new file mode 100644 index 000000000..aad19a619 --- /dev/null +++ b/src/actions/__tests__/audit-log-actions.test.js @@ -0,0 +1,127 @@ +/** + * @jest-environment jsdom + */ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { getRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import { getAuditLog } from "../audit-log-actions"; +import * as methods from "../../utils/methods"; + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + getRequest: jest.fn() +})); + +describe("getAuditLog REVERSED order direction", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + let capturedParams; + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + + capturedParams = null; + getRequest.mockImplementation(() => (params) => { + capturedParams = params; + return () => + Promise.resolve({ + response: { data: [], total: 0, current_page: 1, last_page: 1 } + }); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const buildStore = () => + mockStore({ currentSummitState: { currentSummit: { id: 1 } } }); + + // Regression pin: audit-logs-api's parse_order reads "+" as descending and + // "-" as ascending — the opposite of every other list in this app — so the + // default (newest-first) view must send "+", not "-". + it("sends a '+' prefix for the default (newest-first) orderDir", async () => { + const store = buildStore(); + + store.dispatch(getAuditLog([], "", 1, 100, "created", -1, [])); + await flushPromises(); + + expect(capturedParams.order).toBe("+created"); + }); + + it("sends a '-' prefix when orderDir is flipped to ascending", async () => { + const store = buildStore(); + + store.dispatch(getAuditLog([], "", 1, 100, "created", 1, [])); + await flushPromises(); + + expect(capturedParams.order).toBe("-created"); + }); +}); + +describe("getAuditLog filters contract", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + let capturedParams; + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + + capturedParams = null; + getRequest.mockImplementation(() => (params) => { + capturedParams = params; + return () => + Promise.resolve({ + response: { data: [], total: 0, current_page: 1, last_page: 1 } + }); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const buildStore = () => + mockStore({ currentSummitState: { currentSummit: { id: 1 } } }); + + it("merges summit_id, entityFilter, and the array-shaped grid filter into filter[]", async () => { + const store = buildStore(); + + store.dispatch( + getAuditLog(["event_id==5"], "", 1, 100, "created", -1, ["user_id==42"]) + ); + await flushPromises(); + + expect(capturedParams["filter[]"]).toEqual([ + "summit_id==1", + "event_id==5", + "user_id==42" + ]); + }); + + it("appends entity_id== for a numeric search term", async () => { + const store = buildStore(); + + store.dispatch(getAuditLog([], "123", 1, 100, "created", -1, [])); + await flushPromises(); + + expect(capturedParams["filter[]"]).toEqual([ + "summit_id==1", + "entity_id==123" + ]); + }); + + it("appends action=@ for a non-numeric search term", async () => { + const store = buildStore(); + + store.dispatch(getAuditLog([], "restart", 1, 100, "created", -1, [])); + await flushPromises(); + + expect(capturedParams["filter[]"]).toEqual([ + "summit_id==1", + "action=@restart" + ]); + }); +}); diff --git a/src/actions/audit-log-actions.js b/src/actions/audit-log-actions.js index 0b9cb3d9a..b8c226d3d 100644 --- a/src/actions/audit-log-actions.js +++ b/src/actions/audit-log-actions.js @@ -19,35 +19,19 @@ import { authErrorHandler, escapeFilterValue } from "openstack-uicore-foundation/lib/utils/actions"; +import { getAccessTokenSafely, isNumericString } from "../utils/methods"; import { - getAccessTokenSafely, - isNumericString, - parseDateRangeFilter -} from "../utils/methods"; -import { DEFAULT_CURRENT_PAGE, DEFAULT_ORDER_DIR } from "../utils/constants"; + DEFAULT_CURRENT_PAGE, + DEFAULT_ORDER_DIR, + MAX_PER_PAGE +} from "../utils/constants"; export const CLEAR_LOG_PARAMS = "CLEAR_LOG_PARAMS"; export const REQUEST_LOG = "REQUEST_LOG"; export const RECEIVE_LOG = "RECEIVE_LOG"; -const DEFAULT_PER_PAGE_AUDIT_LOG = 100; - -const parseFilters = (filters, term = null) => { - const filter = []; - - if (filters.created_date_filter) { - parseDateRangeFilter(filter, filters.created_date_filter, "created"); - } - - if ( - filters.hasOwnProperty("user_id_filter") && - Array.isArray(filters.user_id_filter) && - filters.user_id_filter.length > 0 - ) { - filter.push( - `user_id==${filters.user_id_filter.map((t) => t.id).join("||")}` - ); - } +const parseFilters = (filters = [], term = null) => { + const filter = Array.isArray(filters) ? [...filters] : []; if (term) { const escapedTerm = escapeFilterValue(term); @@ -68,18 +52,17 @@ const parseFilters = (filters, term = null) => { export const getAuditLog = ( entityFilter = [], - term = null, + term = "", page = DEFAULT_CURRENT_PAGE, - perPage = DEFAULT_PER_PAGE_AUDIT_LOG, - order = null, - orderDir = DEFAULT_ORDER_DIR, - filters = {} + perPage = MAX_PER_PAGE, + order = "created", + orderDir = -1, + filters = [] ) => async (dispatch, getState) => { const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); const { currentSummit } = currentSummitState; - const summitTZ = currentSummit.time_zone.name; const summitFilter = [`summit_id==${currentSummit.id}`]; dispatch(startLoading()); @@ -99,9 +82,10 @@ export const getAuditLog = params["filter[]"] = parsedFilters; - // order + // order: audit-logs-api's parse_order reads "+" as desc and "-" as asc, + // the opposite of DEFAULT_ORDER_DIR's usual meaning elsewhere in the app. if (order != null && orderDir != null) { - const orderDirSign = orderDir === DEFAULT_ORDER_DIR ? "+" : "-"; + const orderDirSign = orderDir === DEFAULT_ORDER_DIR ? "-" : "+"; params.order = `${orderDirSign}${order}`; } @@ -110,7 +94,7 @@ export const getAuditLog = createAction(RECEIVE_LOG), `${window.AUDIT_LOG_API_BASE_URL}/api/v1/audit-logs`, authErrorHandler, - { page, perPage, order, orderDir, term, summitTZ, filters } + { page, perPage, order, orderDir, term, filters } )(params)(dispatch).then(() => { dispatch(stopLoading()); }); diff --git a/src/components/audit-logs/__tests__/index.test.js b/src/components/audit-logs/__tests__/index.test.js new file mode 100644 index 000000000..029217799 --- /dev/null +++ b/src/components/audit-logs/__tests__/index.test.js @@ -0,0 +1,288 @@ +import React from "react"; +import { + fireEvent, + render, + screen, + waitFor, + within +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { Provider } from "react-redux"; +import { applyMiddleware, combineReducers, createStore } from "redux"; +import thunk from "redux-thunk"; +import { allFiltersReducer } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; +import AuditLogs from "../index"; +import auditLogReducer from "../../../reducers/audit_log/audit-log-reducer"; +import { getAuditLog } from "../../../actions/audit-log-actions"; +import { renderWithRedux } from "../../../utils/test-utils"; + +jest.mock("i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../../../actions/audit-log-actions", () => ({ + getAuditLog: jest.fn(() => ({ type: "MOCK_GET_AUDIT_LOG" })), + clearAuditLogParams: jest.fn(() => ({ type: "MOCK_CLEAR_AUDIT_LOG_PARAMS" })) +})); + +jest.mock("openstack-uicore-foundation/lib/utils/query-actions", () => ({ + queryMembers: (term, callback) => + callback([ + { + id: 42, + first_name: "Jane", + last_name: "Doe", + email: "jane@example.com" + } + ]) +})); + +// Stubs the MUI Autocomplete used by the async "user_id" value field so the +// test can select an option without driving the real popper/listbox. +jest.mock("@mui/material/Autocomplete", () => ({ + __esModule: true, + default: ({ onChange, options }) => ( + + ) +})); + +const currentSummitStateReducer = (state = { currentSummit: {} }) => state; + +const buildStore = () => + createStore( + combineReducers({ + allGridFiltersState: allFiltersReducer, + auditLogState: auditLogReducer, + currentSummitState: currentSummitStateReducer + }), + applyMiddleware(thunk) + ); + +const renderAuditLogs = (props = {}) => { + const store = buildStore(); + render( + + + + ); + return store; +}; + +// Expect a console.error PropTypes warning from GridFilter's `Re` component — +// a pre-existing gap in openstack-uicore-foundation, unrelated to this test. +describe("AuditLogs grid filter", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("applying a user_id filter fetches logs with a non-empty parsed filter", async () => { + renderAuditLogs(); + + await userEvent.click( + screen.getByRole("button", { name: "grid_filter.open_filters" }) + ); + + const dialog = await screen.findByRole("dialog"); + const columnSelect = within(dialog).getAllByRole("combobox")[0]; + await userEvent.click(columnSelect); + await userEvent.click( + await screen.findByRole("option", { + name: "audit_log.placeholders.user_id" + }) + ); + + const selectUserOption = await screen.findByTestId("select-user-option"); + await waitFor(() => expect(selectUserOption).toBeEnabled()); + await userEvent.click(selectUserOption); + + await userEvent.click( + within(dialog).getByRole("button", { name: "grid_filter.apply_filters" }) + ); + + await waitFor(() => { + const appliedCall = getAuditLog.mock.calls.find( + (call) => call[6]?.length > 0 + ); + expect(appliedCall).toBeDefined(); + expect(appliedCall[6]).toEqual(["user_id==42"]); + }); + }); +}); + +const baseAuditLogState = { + term: "", + logEntries: [], + currentPage: 1, + lastPage: 1, + perPage: 10, + order: "created", + orderDir: 1, + totalLogEntries: 0 +}; + +const renderWithAuditLogState = (props = {}, auditLogState = {}) => + renderWithRedux( + , + { + initialState: { + currentSummitState: { currentSummit: {} }, + auditLogState: { ...baseAuditLogState, ...auditLogState } + } + } + ); + +describe("AuditLogs columns", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Guards the regression where the column read the raw audit.action verb + // (create/update/delete/...) instead of the parsed audit.description + // sentence, and where the ticket page's column subset didn't match the + // reducer's field name — both must stay in sync as "action_description". + // The fixture carries both action and action_description, as real reducer + // output always does, so reading the wrong one renders a visibly wrong + // value ("update") rather than an absence. + test("renders the caller's column subset, wired to the reducer's `action_description` key", () => { + renderWithAuditLogState( + { columns: ["created", "action_description", "user"] }, + { + logEntries: [ + { + id: 1, + created: "August 17th 2026, 12:00 pm", + action: "update", + action_description: "Presentation 'Keynote' (6714) updated: title", + event_id: 55, + user: "Jane Doe (7)" + } + ], + totalLogEntries: 1 + } + ); + + // "created" is sortable and currently the active sort column, so MUI + // appends a visually-hidden "sorted ascending" indicator to its header + // text — assert prefixes rather than exact text for that one. + const headers = screen + .getAllByRole("columnheader") + .map((h) => h.textContent); + expect(headers).toHaveLength(3); + expect(headers[0]).toMatch(/^audit_log\.date/); + expect(headers[1]).toBe("audit_log.action"); + expect(headers[2]).toBe("audit_log.user"); + expect( + screen.getByText("Presentation 'Keynote' (6714) updated: title") + ).toBeInTheDocument(); + }); +}); + +describe("AuditLogs sorting", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("clicking a sortable column header re-fetches with that column", () => { + renderWithAuditLogState( + {}, + { + logEntries: [ + { + id: 1, + created: "August 17th 2026, 12:00 pm", + action: "update", + action_description: "Presentation 'Keynote' (6714) updated: title", + event_id: 55, + user: "Jane Doe (7)" + } + ], + totalLogEntries: 1 + } + ); + getAuditLog.mockClear(); + + fireEvent.click(screen.getByText("audit_log.date")); + + expect(getAuditLog).toHaveBeenLastCalledWith( + [], + "", + 1, + 10, + "created", + expect.any(Number), + [] + ); + }); +}); + +describe("AuditLogs pagination", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("changing rows-per-page re-fetches with the new perPage", async () => { + renderWithAuditLogState( + {}, + { + logEntries: [ + { + id: 1, + created: "August 17th 2026, 12:00 pm", + action: "update", + action_description: "Presentation 'Keynote' (6714) updated: title", + event_id: 55, + user: "Jane Doe (7)" + } + ], + totalLogEntries: 30 + } + ); + getAuditLog.mockClear(); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.click(await screen.findByRole("option", { name: "20" })); + + await waitFor(() => { + expect(getAuditLog).toHaveBeenLastCalledWith( + [], + "", + 1, + 20, + "created", + 1, + [] + ); + }); + }); +}); + +describe("AuditLogs empty state", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // At zero rows MuiTable doesn't mount at all, so pagination and the + // per-page selector disappear along with it — documenting current + // behaviour rather than asserting it's desirable. + test("shows the empty message and renders no table when there are no log entries", () => { + renderWithAuditLogState({}, { logEntries: [], totalLogEntries: 0 }); + + expect(screen.getByText("audit_log.no_log_entries")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/audit-logs/index.js b/src/components/audit-logs/index.js index 81cb2a9d6..28c65369f 100644 --- a/src/components/audit-logs/index.js +++ b/src/components/audit-logs/index.js @@ -1,105 +1,141 @@ import React, { useEffect, useState } from "react"; -import FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search" -import Table from "openstack-uicore-foundation/lib/components/table" -import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown" -import MemberInput from "openstack-uicore-foundation/lib/components/inputs/member-input" -import DateTimePicker from "openstack-uicore-foundation/lib/components/inputs/datetimepicker"; +import { Grid2 } from "@mui/material"; +import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; +import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; +import { + GridFilter, + useGridFilter, + OPERATORS +} from "openstack-uicore-foundation/lib/components/mui/grid-filter"; +import { queryMembers } from "openstack-uicore-foundation/lib/utils/query-actions"; +import CustomAlert from "openstack-uicore-foundation/lib/components/mui/custom-alert"; import T from "i18n-react"; -import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; -import { Pagination } from "react-bootstrap"; import { connect } from "react-redux"; import { clearAuditLogParams as clearAuditLogParamsAction, getAuditLog as getAuditLogAction } from "../../actions/audit-log-actions"; -import { - DATE_FILTER_ARRAY_SIZE, - DEFAULT_CURRENT_PAGE -} from "../../utils/constants"; +import { DEFAULT_CURRENT_PAGE } from "../../utils/constants"; + +const FILTER_ID = "audit_log_list"; + +const getCriterias = () => [ + { + key: "user_id", + label: T.translate("audit_log.placeholders.user_id"), + operators: [OPERATORS.IS], + values: { + type: "asyncSelect", + props: { + queryFunction: queryMembers, + formatOption: (m) => ({ + value: m.id, + label: `${m.first_name} ${m.last_name} (${m.email})` + }) + } + }, + customParser: (f) => [`user_id==${f.value.value}`] + }, + { + key: "created", + label: T.translate("audit_log.date"), + operators: [OPERATORS.BEFORE, OPERATORS.AFTER], + values: { + type: "datetime", + props: { + mode: "datetime" + } + } + } +]; const AuditLogs = ({ + filterId, entityFilter = [], - currentSummit, term, logEntries, perPage, - lastPage, currentPage, + totalLogEntries, order, orderDir, columns, getAuditLog, - clearAuditLogParams, - filters + clearAuditLogParams }) => { - const [page, setPage] = useState(currentPage); const [searchTerm, setSearchTerm] = useState(term); - const defaultFilters = { - user_id_filter: [], - created_date_filter: Array(DATE_FILTER_ARRAY_SIZE).fill(null) - }; - - const [enabledFilters, setEnabledFilters] = useState( - Object.keys(filters).filter((e) => - Array.isArray(filters[e]) - ? filters[e]?.some((a) => a !== null) - : filters[e]?.length > 0 - ) - ); - const [auditLogFilters, setAuditLogFilters] = useState({ - ...defaultFilters, - ...filters - }); - - const filtersDdl = [ - { label: "Created", value: "created_date_filter" }, - { label: "Member", value: "user_id_filter" } - ]; - - const auditLogTableOptions = { - sortCol: order, - sortDir: orderDir, - actions: {} - }; + // filterId is a category literal ("standalone"/"activity"/"badge"), not a + // per-entity id: the grid filter is shared across all entities in that + // category (can leak between them, e.g. a stale date range) rather than + // reset per entity, which can't be told apart from applying a filter, or + // persisted per entity, which grows unbounded in localStorage. + const gridFilterId = `${FILTER_ID}_${filterId}`; + const { parsedFilter } = useGridFilter(gridFilterId); + const userTimeZone = new Intl.DateTimeFormat(undefined, { + timeZoneName: "long" + }) + .formatToParts(new Date()) + .find((part) => part.type === "timeZoneName").value; const auditLogColumns = [ { columnKey: "created", - value: T.translate("audit_log.date"), + header: T.translate("audit_log.date"), sortable: true }, { columnKey: "action_description", - value: T.translate("audit_log.action"), - sortable: false + header: T.translate("audit_log.action"), + sortable: false, + width: 600, + truncateText: true }, { columnKey: "event_id", - value: T.translate("audit_log.event"), + header: T.translate("audit_log.event"), sortable: true }, - { columnKey: "user", value: T.translate("audit_log.user"), sortable: false } + { + columnKey: "user", + header: T.translate("audit_log.user"), + sortable: false + } ]; const showColumns = columns ? auditLogColumns.filter((c) => columns.includes(c.columnKey)) : auditLogColumns; - const handleSort = (_index, key, dir) => { - setPage(1); + useEffect(() => { + // we reset pagination and search but keep the filters within a category, see comment above. + setSearchTerm(""); + getAuditLog( + entityFilter, + undefined, + undefined, + undefined, + undefined, + undefined, + parsedFilter + ); + }, [parsedFilter.join(","), entityFilter.join(","), filterId]); + + // AuditLogs is reused in different contexts only reset the log params, not filters. + useEffect(() => () => clearAuditLogParams(), []); + + const handleSort = (key, dir) => { getAuditLog( entityFilter, searchTerm, - 1, + DEFAULT_CURRENT_PAGE, perPage, key, dir, - auditLogFilters + parsedFilter ); }; const handlePageChange = (newPage) => { - setPage(newPage); getAuditLog( entityFilter, searchTerm, @@ -107,221 +143,81 @@ const AuditLogs = ({ perPage, order, orderDir, - auditLogFilters - ); - }; - - const handleSearch = (newTerm) => { - setSearchTerm(newTerm); - setPage(1); - getAuditLog( - entityFilter, - newTerm, - 1, - perPage, - order, - orderDir, - auditLogFilters + parsedFilter ); }; - const handleDDLSortByLabel = (ddlArray) => - ddlArray.sort((a, b) => a.label.localeCompare(b.label)); - - const handleFiltersChange = (ev) => { - const { value } = ev.target; - if (value.length < enabledFilters.length) { - if (value.length === 0) { - setEnabledFilters(value); - setAuditLogFilters(defaultFilters); - } else { - const removedFilter = enabledFilters.filter( - (e) => !value.includes(e) - )[0]; - const defaultValue = Array.isArray(auditLogFilters[removedFilter]) - ? [] - : ""; - const newEventFilters = { - ...auditLogFilters, - [removedFilter]: defaultValue - }; - setEnabledFilters(value); - setAuditLogFilters(newEventFilters); - } - } else { - setEnabledFilters(value); - } - }; - - const handleChangeDateFilter = (ev, lastDate) => { - const { value, id } = ev.target; - const newDateFilter = auditLogFilters[id]; - - setAuditLogFilters({ - ...auditLogFilters, - [id]: lastDate - ? [newDateFilter[0], value.unix()] - : [value.unix(), newDateFilter[1]] - }); - }; - - const handleAuditLogFilterChange = (ev) => { - const { value, id } = ev.target; - setAuditLogFilters({ ...auditLogFilters, [id]: value }); - }; - - const handleApplyAuditLogFilters = () => { - setPage(1); + const handlePerPageChange = (newPerPage) => { getAuditLog( entityFilter, searchTerm, - 1, - perPage, + DEFAULT_CURRENT_PAGE, + newPerPage, order, orderDir, - auditLogFilters + parsedFilter ); }; - const getUserFieldValue = (member) => - `${member.first_name ?? ""} ${member.last_name ?? ""} (${ - member.email ?? member.id - })`; - - useEffect(() => { + const handleSearch = (newTerm) => { + setSearchTerm(newTerm); getAuditLog( entityFilter, - searchTerm, + newTerm, DEFAULT_CURRENT_PAGE, perPage, order, orderDir, - filters + parsedFilter ); + }; - return () => { - clearAuditLogParams(); - }; - }, []); + const tableOptions = { + sortCol: order, + sortDir: orderDir + }; return ( <> -
-
- + + -
-
- -
- -
-
- -
-
- -
-
-
- {enabledFilters.includes("user_id_filter") && ( -
- -
- )} - {enabledFilters.includes("created_date_filter") && ( - <> -
- handleChangeDateFilter(ev, false)} - timezone={currentSummit.time_zone_id} - value={epochToMomentTimeZone( - auditLogFilters.created_date_filter[0], - currentSummit.time_zone_id - )} - className="event-list-date-picker" - /> -
-
- handleChangeDateFilter(ev, true)} - timezone={currentSummit.time_zone_id} - value={epochToMomentTimeZone( - auditLogFilters.created_date_filter[1], - currentSummit.time_zone_id - )} - className="event-list-date-picker" - /> -
- - )} -
+ + + + {logEntries.length === 0 && (
{T.translate("audit_log.no_log_entries")}
)} {logEntries.length > 0 && ( - <> - - - + )} ); diff --git a/src/components/forms/event-form/index.js b/src/components/forms/event-form/index.js index 12a2addf6..25f761796 100644 --- a/src/components/forms/event-form/index.js +++ b/src/components/forms/event-form/index.js @@ -2240,10 +2240,8 @@ class EventForm extends React.Component { handleClick={this.toggleSection.bind(this, "audit_log")} > {entity.id !== 0 && ( diff --git a/src/i18n/en.json b/src/i18n/en.json index c3c0e36b2..9a27ef9a3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -3791,6 +3791,7 @@ "event": "Event", "order": "Order", "apply_filters": "Apply Filters", + "timezone_info": "All dates appear in user local timezone {tz}", "placeholders": { "search_log": "Search By Action / Entity Id / OTEL Span ID / OTEL Trace ID / Ray ID", "user_id": "Filter by User", diff --git a/src/pages/audit-log/__tests__/audit-log-page.test.js b/src/pages/audit-log/__tests__/audit-log-page.test.js new file mode 100644 index 000000000..05ad55d6a --- /dev/null +++ b/src/pages/audit-log/__tests__/audit-log-page.test.js @@ -0,0 +1,53 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import "@testing-library/jest-dom"; +import { screen } from "@testing-library/react"; +import { renderWithRedux } from "../../../utils/test-utils"; +import AuditLogPage from "../audit-log-page"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../../../components/audit-logs", () => ({ + __esModule: true, + default: (props) => ( +
{JSON.stringify(props)}
+ ) +})); + +const renderPage = (totalLogEntries) => + renderWithRedux(, { + initialState: { auditLogState: { totalLogEntries } } + }); + +describe("AuditLogPage", () => { + it("renders the log entries count in the heading", () => { + renderPage(42); + + expect(screen.getByRole("heading", { level: 3 }).textContent).toBe( + "audit_log.log_entries (42)" + ); + }); + + it("scopes AuditLogs to the standalone context, filtered to SummitEvent audit logs", () => { + renderPage(0); + + const props = JSON.parse(screen.getByTestId("audit-logs-mock").textContent); + expect(props.filterId).toBe("standalone"); + expect(props.entityFilter).toEqual(["class_name==SummitEvent"]); + }); +}); diff --git a/src/pages/audit-log/audit-log-page.js b/src/pages/audit-log/audit-log-page.js index 4012421f1..425475f32 100644 --- a/src/pages/audit-log/audit-log-page.js +++ b/src/pages/audit-log/audit-log-page.js @@ -9,26 +9,24 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. - **/ + * */ import React from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; import AuditLogs from "../../components/audit-logs"; -import { Breadcrumb } from "react-breadcrumbs"; -const AuditLogPage = ({ totalLogEntries, match }) => { - return ( -
- -

- {" "} - {T.translate("audit_log.log_entries")} ({totalLogEntries}) -

- -
- ); -}; +const AuditLogPage = ({ totalLogEntries }) => ( +
+

+ {T.translate("audit_log.log_entries")} ({totalLogEntries}) +

+ +
+); const mapStateToProps = ({ auditLogState }) => ({ ...auditLogState diff --git a/src/pages/orders/edit-ticket-page.js b/src/pages/orders/edit-ticket-page.js index b688da878..3e62f10b0 100644 --- a/src/pages/orders/edit-ticket-page.js +++ b/src/pages/orders/edit-ticket-page.js @@ -516,11 +516,12 @@ const EditTicketPage = ({ > {entity.badge && ( )} diff --git a/src/reducers/audit_log/__tests__/audit-log-reducer.test.js b/src/reducers/audit_log/__tests__/audit-log-reducer.test.js new file mode 100644 index 000000000..27f407aaf --- /dev/null +++ b/src/reducers/audit_log/__tests__/audit-log-reducer.test.js @@ -0,0 +1,62 @@ +import moment from "moment-timezone"; +import { formatAuditLog, parseSpeakerAuditLog } from "../audit-log-reducer"; + +describe("formatAuditLog", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("converts an embedded UTC datetime to the viewer's local timezone", () => { + jest.spyOn(moment.tz, "guess").mockReturnValue("America/New_York"); + + const result = formatAuditLog( + "Presentation updated on 2026-06-01 12:00:00 by admin" + ); + + // 2026-06-01 12:00:00 UTC is 08:00:00 in America/New_York (EDT, UTC-4) + expect(result).toBe("Presentation updated on 2026-06-01 08:00:00 by admin"); + }); + + it("returns the string unchanged when it has no embedded datetime", () => { + expect(formatAuditLog("Presentation updated by admin")).toBe( + "Presentation updated by admin" + ); + }); +}); + +describe("parseSpeakerAuditLog", () => { + it("reports a single addition", () => { + const result = parseSpeakerAuditLog( + "Speaker 'Jane Doe' (jane@example.com) added as featured speaker" + ); + + expect(result).toBe("Speaker jane@example.com was added to the collection"); + }); + + it("reports a single removal", () => { + const result = parseSpeakerAuditLog( + "Speaker 'Jane Doe' (jane@example.com) removed from featured speakers" + ); + + expect(result).toBe( + "Speaker jane@example.com was removed from the collection" + ); + }); + + it("returns the original string when an add and a remove for the same speaker net out to zero", () => { + const original = + "Speaker 'Jane Doe' (jane@example.com) added as featured speaker|Speaker 'Jane Doe' (jane@example.com) removed from featured speakers"; + + expect(parseSpeakerAuditLog(original)).toBe(original); + }); + + it("joins net changes for multiple speakers", () => { + const result = parseSpeakerAuditLog( + "Speaker 'Jane Doe' (jane@example.com) added as featured speaker|Speaker 'John Roe' (john@example.com) removed from featured speakers" + ); + + expect(result).toBe( + "Speaker jane@example.com was added to the collection|Speaker john@example.com was removed from the collection" + ); + }); +}); diff --git a/src/reducers/audit_log/audit-log-reducer.js b/src/reducers/audit_log/audit-log-reducer.js index dee803c66..275e231ee 100644 --- a/src/reducers/audit_log/audit-log-reducer.js +++ b/src/reducers/audit_log/audit-log-reducer.js @@ -12,7 +12,10 @@ * */ import moment from "moment-timezone"; -import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; +import { + epochToMoment, + epochToMomentTimeZone +} from "openstack-uicore-foundation/lib/utils/methods"; import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; import { CLEAR_LOG_PARAMS, @@ -21,19 +24,68 @@ import { } from "../../actions/audit-log-actions"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; -import { formatAuditLog, parseSpeakerAuditLog } from "../../utils/methods"; +import { MAX_PER_PAGE } from "../../utils/constants"; const DEFAULT_STATE = { term: "", logEntries: [], currentPage: 1, lastPage: 1, - perPage: 10, + perPage: MAX_PER_PAGE, order: "created", - orderDir: 1, - totalLogEntries: 0, - summitTZ: "", - filters: {} + orderDir: -1, + totalLogEntries: 0 +}; + +export const formatAuditLog = (logString) => { + const timeZone = moment.tz.guess(); + const dateTimeRegExp = /\d{4}([.\-/ ])\d{2}\1\d{2} \d{1,2}:\d{2}:\d{2}/g; + const dateTimeMatch = logString.match(dateTimeRegExp); + if (!dateTimeMatch) return logString; + const dt = moment.utc(dateTimeMatch[0], "YYYY-MM-DD HH:mm:ss"); + if (!moment.isMoment(dt)) return logString; + const userDt = epochToMomentTimeZone(dt.unix(), timeZone); + if (!moment.isMoment(userDt)) return logString; + return logString.replace( + dateTimeMatch[0], + userDt.format("YYYY-MM-DD HH:mm:ss") + ); +}; + +export const parseSpeakerAuditLog = (logString) => { + const logEntries = logString.split("|"); + const userChanges = {}; + const emailRegExp = + /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; + // eslint-disable-next-line + for (const entry of logEntries) { + const emailMatch = entry.match(emailRegExp); + if (!emailMatch) continue; + const email = emailMatch[0]; + if (entry.includes("added")) { + // eslint-disable-next-line no-magic-numbers + userChanges[email] = (userChanges[email] || 0) + 1; + } else if (entry.includes("removed")) { + // eslint-disable-next-line no-magic-numbers + userChanges[email] = (userChanges[email] || 0) - 1; + } + } + + const relevantChanges = []; + // eslint-disable-next-line + for (const [email, changeCount] of Object.entries(userChanges)) { + if (changeCount !== 0) { + relevantChanges.push( + `Speaker ${email} ${ + changeCount > 0 + ? "was added to the collection" + : "was removed from the collection" + }` + ); + } + } + + return relevantChanges.length > 0 ? relevantChanges.join("|") : logString; }; // eslint-disable-next-line default-param-last @@ -46,16 +98,17 @@ const auditLogReducer = (state = DEFAULT_STATE, action) => { return DEFAULT_STATE; } case REQUEST_LOG: { - const { term, order, orderDir, summitTZ } = payload; - return { ...state, term, order, orderDir, summitTZ }; + const { term, order, orderDir, perPage } = payload; + return { ...state, term, order, orderDir, perPage }; } case RECEIVE_LOG: { const { current_page, total, last_page } = payload.response; const logEntries = payload.response.data.map((e) => { - const logEntryAction = e.action.startsWith("Speaker") - ? parseSpeakerAuditLog(e.action) - : e.action; + const rawDescription = e.action_description ?? ""; + const parsedDescription = rawDescription.startsWith("Speaker") + ? parseSpeakerAuditLog(rawDescription) + : rawDescription; const userFullName = `${e.user?.first_name ?? ""} ${ e.user?.last_name ?? "" }`.trim(); @@ -66,10 +119,8 @@ const auditLogReducer = (state = DEFAULT_STATE, action) => { user: `${userFullName || e.user.email} ${ e.user?.id ? `(${e.user.id})` : "" }`, - created: moment( - epochToMomentTimeZone(e.created, state.summitTZ) - ).format("MMMM Do YYYY, h:mm a"), - action: formatAuditLog(logEntryAction) + created: epochToMoment(e.created).format("MMMM Do YYYY, h:mm a"), + action_description: formatAuditLog(parsedDescription) }; }); diff --git a/src/utils/methods.js b/src/utils/methods.js index a6614745b..6c1620847 100644 --- a/src/utils/methods.js +++ b/src/utils/methods.js @@ -11,6 +11,13 @@ * limitations under the License. */ +/* + * ******************************* IMPORTANT ********************************** + * This utils file should hold only methods used globally across files/components, + * not methods used for one specific purpose/file + * ****************************************************************************** + * */ + import moment from "moment-timezone"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; import { @@ -312,57 +319,6 @@ export const validateAllowedEmailDomainEntry = (entry) => { ); }; -export const parseSpeakerAuditLog = (logString) => { - const logEntries = logString.split("|"); - const userChanges = {}; - const emailRegExp = - /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; - // eslint-disable-next-line - for (const entry of logEntries) { - const emailMatch = entry.match(emailRegExp); - if (!emailMatch) continue; - const email = emailMatch[0]; - if (entry.includes("added")) { - // eslint-disable-next-line no-magic-numbers - userChanges[email] = (userChanges[email] || 0) + 1; - } else if (entry.includes("removed")) { - // eslint-disable-next-line no-magic-numbers - userChanges[email] = (userChanges[email] || 0) - 1; - } - } - - const relevantChanges = []; - // eslint-disable-next-line - for (const [email, changeCount] of Object.entries(userChanges)) { - if (changeCount !== 0) { - relevantChanges.push( - `Speaker ${email} ${ - changeCount > 0 - ? "was added to the collection" - : "was removed from the collection" - }` - ); - } - } - - return relevantChanges.length > 0 ? relevantChanges.join("|") : logString; -}; - -export const formatAuditLog = (logString) => { - const timeZone = moment.tz.guess(); - const dateTimeRegExp = /\d{4}([.\-/ ])\d{2}\1\d{2} \d{1,2}:\d{2}:\d{2}/g; - const dateTimeMatch = logString.match(dateTimeRegExp); - if (!dateTimeMatch) return logString; - const dt = moment.utc(dateTimeMatch[0], "YYYY-MM-DD HH:mm:ss"); - if (!moment.isMoment(dt)) return logString; - const userDt = epochToMomentTimeZone(dt.unix(), timeZone); - if (!moment.isMoment(userDt)) return logString; - return logString.replace( - dateTimeMatch[0], - userDt.format("YYYY-MM-DD HH:mm:ss") - ); -}; - export const getAvailableBookingDates = (summit) => { const isValidStartDate = new Date(summit.begin_allow_booking_date).getTime() > 0;