Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 5 additions & 24 deletions src/actions/audit-log-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,7 @@ import {
authErrorHandler,
escapeFilterValue
} from "openstack-uicore-foundation/lib/utils/actions";
import {
getAccessTokenSafely,
isNumericString,
parseDateRangeFilter
} from "../utils/methods";
import { getAccessTokenSafely, isNumericString } from "../utils/methods";
import { DEFAULT_CURRENT_PAGE, DEFAULT_ORDER_DIR } from "../utils/constants";

export const CLEAR_LOG_PARAMS = "CLEAR_LOG_PARAMS";
Expand All @@ -32,22 +28,8 @@ 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);
Expand All @@ -73,13 +55,12 @@ export const getAuditLog =
perPage = DEFAULT_PER_PAGE_AUDIT_LOG,
order = null,
orderDir = DEFAULT_ORDER_DIR,
filters = {}
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());
Expand Down Expand Up @@ -110,7 +91,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());
});
Expand Down
280 changes: 280 additions & 0 deletions src/components/audit-logs/__tests__/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
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 }) => (
<button
type="button"
data-testid="select-user-option"
disabled={!options?.length}
onClick={() => onChange({}, options[0])}
>
select-user-option
</button>
)
}));

const currentSummitStateReducer = (state = { currentSummit: {} }) => state;

const buildStore = () =>
createStore(
combineReducers({
allGridFiltersState: allFiltersReducer,
auditLogState: auditLogReducer,
currentSummitState: currentSummitStateReducer
}),
applyMiddleware(thunk)
);

const renderAuditLogs = (props = {}) => {
const store = buildStore();
render(
<Provider store={store}>
<AuditLogs filterId="test-entity" entityFilter={[]} {...props} />
</Provider>
);
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(
<AuditLogs filterId="test-entity" entityFilter={[]} {...props} />,
{
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".
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_description: "Updated Event 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("Updated Event 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_description: "Updated Event 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_description: "Updated Event 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();
});
});
Loading
Loading