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
279 changes: 279 additions & 0 deletions src/components/audit-logs/__tests__/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
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 master regression where this column was declared as
// "action_description" while the reducer emits "action", silently
// dropping the Action column on the ticket page (fixed in c0ef613).
test("renders the caller's column subset, wired to the reducer's `action` key", () => {
renderWithAuditLogState(
{ columns: ["created", "action", "user"] },
{
logEntries: [
{
id: 1,
created: "August 17th 2026, 12:00 pm",
action: "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: "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: "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