Skip to content

🧪 fix: Serve Roles, Groups, Grants and the Audit Log from the E2E Mock and Update the Access and Grants Specs - #202

Merged
danny-avila merged 6 commits into
mainfrom
pr/fu-e2e-mock-fixtures
Oct 28, 2026
Merged

danny-avila merged 6 commits into
mainfrom
pr/fu-e2e-mock-fixtures

Conversation

@danny-avila

@danny-avila danny-avila commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Pull Request Template

⚠️ Before Submitting a PR, Please Review:

  • Please ensure that you have thoroughly read and understood the Contributing Docs before submitting your Pull Request.

⚠️ Documentation Updates Notice:

  • Kindly note that documentation updates are managed in this repository: librechat.ai

Summary

Stacks on #190, which adds GET /api/admin/grants/effective to the mock. Merge #190 first.

e2e/mock-backend.mjs served none of the admin API routes that the Access and Grants pages read, so most of access.spec.ts and grants.spec.ts failed against it. The specs were also written before the server functions were wired to LibreChat's admin API, and several of them assert UI that the panel doesn't have. This PR changes only the mock and the e2e specs, not the app.

Mock backend

  • One fixtures section holds what LibreChat's admin API would serve:
    • Users: Test Admin, Alice Chen, Bob Martinez, Carol Nguyen, David Kim and Erin Walsh.
    • Roles: ADMIN and USER with LibreChat's default permissions, plus Auditor, Billing and Support.
    • Groups: Engineering, with four members, and Marketing.
    • Role grants: ADMIN holds every capability, as LibreChat seeds it.
    • A seven-entry grant history recorded by two admins.
  • There is one handler for each route the two pages call. Each returns LibreChat's response shape, taken from LibreChat's packages/api/src/admin handlers and the admin route files:
    • Roles: list, create, read, update, delete, permissions, and list, add and remove members.
    • Groups: list, create, update, delete, and list, add and remove members. The list's search is a case-insensitive substring match on the name, email and description, as in LibreChat's buildGroupQuery.
    • Users: search, matching word prefixes like LibreChat's admin user search.
    • Grants: list, read one role's grants, assign and revoke.
    • Audit log: list, newest first, with LibreChat's filters, offset paging and nextCursor; and the CSV export. The export follows LibreChat's exportAuditLogCsvHandler: a UTF-8 BOM, the same 17 columns and cell escaping, CRLF line ends, newest first, the list's filters, and the same Content-Type, Content-Disposition and Cache-Control headers. A single entry by id (GET /api/admin/audit-log/:id) answers like LibreChat's getAuditLogEntryHandler: { entry }, 400 Invalid id for a malformed id and 404 Not found for an unknown one. The panel asks for it when a permalink opens the drawer.
  • Writes change the fixtures, so the create, edit and delete flows work. Where the panel can see a side effect, it matches LibreChat's:
    • Deleting a role moves its users to USER and removes its grants, recording a grant.removed entry for each grant.
    • Assigning or revoking a grant appends an entry to the hash-chained audit log.
    • Renaming a role moves its users but not its grants, which is also what LibreChat does. LibreChat's renameRole (packages/api/src/admin/roles.ts) only calls updateUsersByRole and updateRoleByName, and updateRoleByName (packages/data-schemas/src/methods/role.ts) only $sets the role document. Grants, ACL entries and config profiles stay keyed by the old name, so a role created later under that name gets them back. A test checks this (see below).
  • The mock refuses the same requests LibreChat refuses when the pages can send them:
    • A system role can't be deleted, renamed or given members (403).
    • An unknown role, group or user returns 404.
    • A malformed id returns 400.
    • A grant for anything other than a role returns 400 with LibreChat's Invalid principal type, because LibreChat grants capabilities only to roles.
  • Routes are keyed METHOD /path with :param segments and matched in the order they are declared. Every admin route uses the existing token check.
  • There is no reset route because no spec needs one: every test that writes uses a unique name and deletes what it created.
  • Two routes are not served:
    • GET /api/admin/audit-log/:id: no spec deep-links an entry.
    • GET /api/admin/config/meta: the app shell requests it on every page and treats the 404 as an older LibreChat.

Spec changes. Each change follows what the panel does now. The kebab menus, reordering, type filter and user-grant button were already absent in this repo's first commit.

The spec asserted What the panel does Change
Role names Admin and Billing Shows role names as LibreChat stores them. The system roles are ADMIN and USER. Assert ADMIN. The tab and role-search tests now use USER instead of Billing, so they also run against a live LibreChat.
getByRole('searchbox') click-ui's SearchField renders a plain text input. getByRole('textbox', { name: /^search/i })
getByText(name) after creating a group or role The success toast and its live region repeat the name, so the locator hits a strict-mode error. exact: true
Group members visible as soon as the group's dialog opens The dialog opens on Details. Members are on the Members tab. Click Members first.
Kebab menus ("actions engineering", "actions admin") Each row is a button that opens the edit dialog, next to a Delete button. "Opens members dialog via kebab menu" now opens the dialog from the keyboard instead. The system-role test checks that ADMIN has no Delete button while Create role is enabled, and that its dialog has a Members tab. The delete test uses the Delete button.
Keyboard reordering of roles ([data-item-id]) Roles and groups can't be reordered. Test removed.
A Roles type filter, and .badge-role on each grants row The table lists only roles, because LibreChat grants capabilities only to roles. "Lists roles only" opens each row and checks the role badge in its dialog.
An "Add user grant" button (2 tests) No such button, for the same reason. Both tests removed. The tab-switching test checks for the ADMIN row instead.
"Export button is present" Export downloads a CSV of every entry that matches the filters. Replaced by two tests that download the export (see below).
"Shows all roles and groups even with 0 grants" Lists roles only. Retitled. It now also checks that USER shows "No capabilities".
The sidebar's Grants link The dashboard's Grants card is a second link with that name. exact: true

New tests:

  • "A renamed role leaves its grants under its old name" (access.spec.ts) creates a role, grants it Read usage on the Grants page, renames it and checks that the renamed role shows "No capabilities". It then renames the role back, checks that the grant shows again, and deletes the role. It runs against the mock only (see Live runs). Its teardown renames the role back to its original name, whatever name it has when the test stops, and then deletes it, so the grant goes with it.
  • "Export downloads only the entries the filters match, as CSV" (grants.spec.ts) creates a role, grants it Read usage, searches the audit log for the role's name and exports. The CSV must hold exactly that grant.assigned entry, with the role as the target and read:usage as the capability. It runs against the mock only (see Live runs).
  • "Export writes the seeded entry the filters match" (mock only) searches for Alice, selects Revoked and checks every column of the one seeded entry those filters match.
  • Both export tests check the file name (audit-log-YYYY-MM-DD.csv), the BOM, the header row and the rows. They also check the Content-Type and Content-Disposition of the panel's export response, which exportAuditLogServerFn sets itself, so those two assertions cover the panel rather than the mock's headers.
  • The helpers that create, rename, grant and delete move to e2e/access.ts, which both specs import.

Race fixes in grants.spec.ts:

  • "Shows diverse audit entries" counted rows before the log loaded, so it saw one row: the spinner. It now waits for the first entry. "Across principal types" is dropped from its title because grant entries always target roles.
  • The Granted, Revoked and search filter tests slept 300 ms and then read the rows. They now wait until no row fails the filter. They match the Granted and Revoked badges exactly, so a target whose name contains one of those words can't match.

Live runs (E2E_LIVE=1).

  • LibreChat writes an audit entry for every role, group and grant write (role.created, role.updated, role.deleted, group.created, group.deleted, grant.assigned, grant.removed and so on) and for every audit-log export (audit.exported). Its admin API can't delete audit entries. So the 5 tests whose purpose is such a write run against the mock only, and skip live with AUDITED_WRITES_SKIP_REASON, which this PR adds to e2e/live.ts:
    • "creates a new group"
    • "creates a new role"
    • "deletes a non-system role"
    • the role rename test
    • the export test that creates its own role.
  • 10 tests read fixtures that only the mock has: Engineering's members, five or more roles and the grant history. They skip with MOCK_FIXTURES_SKIP_REASON, which this PR also adds to e2e/live.ts. A fresh LibreChat seeds only ADMIN and USER. Its admin API also can't stage the members or the history without leaving data behind, because it can't create users and it never deletes audit entries. The six Audit Log tests that read the seeded history sit in a seeded history describe that skips live. This way the tab's beforeEach, which opens the log, doesn't run for them, and they don't write audit.read entries.
  • The rest run live and only read, with two exceptions, both of which write audit entries:
    • The four Groups-tab tests (the Groups axe scan, both tab switches and the group name filter) list groups, and a fresh LibreChat has none. They take a worker-scoped groups fixture. Against the mock it names Engineering and Marketing. Live, it creates both groups, in a page of its own so the creation toasts never reach a test's page, and deletes them when the worker ends. Live, access.spec.ts runs in order in one worker, so the groups are staged once per run, and again only after a failed test, which starts a new worker. Each staging writes 2 group.created entries and 4 group.deleted entries, because LibreChat records each group delete as pending and then as success.
    • Every read of the audit log writes an audit.read entry: 7 per run, from the six live tests that open the Audit Log tab. These entries name no role or group.
  • The fixture tracks each group's name before creating it, and deletes every tracked group in a finally. That still runs when staging fails part-way, for example when the second group can't be created.
  • A staging step that stalls can't outlast the fixture. The Playwright config sets no action or navigation timeout, so a stalled click or page load would wait for the fixture's own timeout. Playwright would then fail the test and close the browser under that step, the finally would run against a closed context, and the groups already created would stay behind. So each staging and cleanup action and page load fails after 10 s, and the fixture has its own 60 s timeout. A stalled step throws while the browser is still open. The finally then closes the staging context and deletes the groups from a fresh context with the same sign-in, so cleanup works whatever state staging left its page in.
  • The Groups tab lists 50 groups a page, sorted by name. On a LibreChat with more groups, a staged group may not be on the first page. So the Groups-tab tests search for the full staged name before they assert that it is listed. The tab-switch tests wait for the Groups tab's own search field, and the name filter test applies its filter before it asserts anything.
  • Every role and group a test creates is named with a random UUID (Engineering 3f0c…). Names built from Date.now() alone could repeat across parallel workers: LibreChat refuses a second role with the same name, and two groups with the same name have two identical Delete buttons, which fails Playwright's strict mode.
  • Every test that creates a group or role deletes it afterwards, including when it fails. deleteFromList searches for each name and deletes only the rows the tab lists. It waits until the row is gone, and it tries every name even if an earlier delete fails. A test sets the name it will delete before it submits the create dialog, so a create that succeeds and then times out is still cleaned up. On 🧪 fix: Serve the Admin's Effective Capabilities from the E2E Mock Backend #190 the create tests never cleaned up, and its live run left one group and two roles behind.

Known limits of the live group staging. Live runs can still leave a UUID-named test group behind in two cases. No injected stall of a Playwright step leaks one any more.

  • A create request the server finishes more than about 7 seconds after the panel gave up on it. Cleanup has already searched for the group by then.
  • A server slow enough that staging plus cleanup runs past the fixture's 60-second budget. With every request delayed 3 seconds, one of the two groups was left.

Each would need cleanup to wait for in-flight creates and retry until the budget runs out. That only matters on a slow live test backend, so it is left for later.

Not in this PR. Three axe tests in grants.spec.ts still fail, against the mock and against a live LibreChat alike. They also failed in the live run on #190, whose panel code is the same as main's. The cause is in the panel, not in the fixtures:

  • "management tab", "edit dialog", and "audit log tab" once the log has entries (color-contrast): the success badge (Active in the grants table, Granted in the audit log) uses click-ui's --click-badge-opaque-color-text-success, #008a0b on #ebffec. That is 4.32:1 at 10px, and AA needs 4.5:1.
  • "audit log tab" (nested-interactive): each date filter is click-ui's DatePicker. Its trigger button contains the readonly input, and tabIndex = -1 still leaves the input focusable.
  • "edit dialog" (nested-interactive): each category header in CapabilityPanel is a div role="button" that contains its "Select all" switch.

A fourth issue shows up only when a scan runs while a toast is open, so no test here fails on it. click-ui's toast viewport is an ol whose toasts are li role="status" (axe list), and Radix's focus-proxy spans are aria-hidden but have tabindex="0" (aria-hidden-focus). A live Groups-tab scan hit it while the staging toasts were still open, which is why the groups fixture stages its groups in a page of its own. All four issues need a panel a11y fix and are outside this fixtures PR.

Change Type

  • Bug fix (non-breaking change which fixes an issue)

Testing

Run Passed Failed Skipped
The old specs against this mock (access + grants) 22 26 0
This branch's specs against #190's mock 8 37 0
This branch, access + grants, mock 46 3 0
This branch, access + grants, mock, --workers=4 --repeat-each=3 130 9 0
This branch, whole suite, mock 72 3 137
#190, access + grants, live, fresh database 14 34 0
This branch, access + grants, live, --workers=4 29 3 15
This branch, access + grants, live, fresh database, --workers=4 --repeat-each=2 57 6 30
This branch, access.spec.ts, live, with 60 extra groups that sort first, --workers=4 --repeat-each=2 25 0 14
This branch, access.spec.ts, live, --workers=4 13 0 7
This branch, access.spec.ts, live, --workers=4 --repeat-each=2 25 0 14
  • Every failure on this branch is one of the three axe tests above. The repeated runs had no other failures.
  • In the first row, 23 of the failures are assertions this PR changes and the other 3 are the axe tests. In the second row, the specs fail because the old mock serves no roles, groups or grants.
  • For comparison, 🧪 fix: Serve the Admin's Effective Capabilities from the E2E Mock Backend #190 reports 35 passed, 41 failed and 137 skipped for the whole suite against its mock. This branch has 212 tests instead of 213: 4 were removed and 3 added. login.spec.ts still passes in full.
  • Live: the roles, groups and grants were the same before and after each run: ADMIN and USER, no groups, and ADMIN's 25 grants.
  • Live audit entries: the database started fresh, with no audit entries.
    • The --workers=4 --repeat-each=2 run added 26 entries: 14 audit.read, 4 group.created and 8 group.deleted.
    • A further --workers=4 run added 13: 7 audit.read, 2 group.created and 4 group.deleted.
    • Neither run added a role.*, grant.* or audit.exported entry.
    • The previous revision of this PR, run the same way (--workers=4 --repeat-each=2), added 132 entries: 30 audit.read, 4 audit.exported, 4 grant.assigned, 4 grant.removed, 18 group.created, 36 group.deleted, 8 role.created, 16 role.deleted, 8 role.permissions_updated and 4 role.updated.
  • Cleanup when a test fails. I checked each case below with a throwaway patch that is not committed:
    • Group staging: the patch made the groups fixture throw after creating its first group, and in a second run after creating both. In both live runs, every group the fixture created was deleted. The previous fixture, given the same failure after the first group, left 4 groups behind. The run that throws after both groups was repeated on the final fixture, and it deleted all 8 groups it created.
    • Stalled group staging: in four more live runs the patch stalled or broke a staging step. Each run staged four times, once for each Groups-tab test, and every group it created was deleted:
      • The second group's name was left empty, so its Create click waited on a disabled button. It failed after 10 s, and the run deleted the 4 groups it created.
      • After both groups existed, the staging page reloaded while its page load got no answer. It failed after 10 s, and the run deleted 8 groups.
      • After both groups existed, staging waited for a group that is never listed. It failed after 10 s, and the run deleted 8 groups.
      • The staging context was closed after the first group, so the second creation failed at once and cleanup ran in the fresh context. The run deleted 4 groups.
    • The previous fixture, given the first two stalls, hit its 30 s fixture timeout each time. Its cleanup then failed with Target page, context or browser has been closed or Test ended, and the two runs left 4 and 8 groups behind.
    • Role rename: the patch failed the rename test at three points, after the grant, after the rename and after the rename back. Afterwards no role and no grant was left under either name, on the mock and on a live LibreChat. With the previous teardown, the same three failures left a role and its grant behind each time on the mock.
  • Paging: 60 extra groups named AAA Seed Group 01–60 sort before the staged groups, so the first page doesn't show the staged groups. With those groups added, the previous Groups-tab tests failed all four, and this branch's access.spec.ts passed in full. The 60 groups were removed afterwards.
  • The new tests fail when the mock is wrong. With the export route removed, both export tests time out waiting for the download. With a rename that moves the grants to the new name, the rename test fails because the renamed role shows "1 capabilities".
  • Against the mock directly: a group search for ineer, product or MARKET finds Engineering, Engineering and Marketing. A user search for ali che finds Alice Chen. A grant read or write for a user or group principal gets {"error":"Invalid principal type"}.
  • I also exercised the edit flows through the panel against the mock, using a throwaway spec that is not committed:
    • Edited Engineering: new description, added Erin Walsh, removed Bob Martinez.
    • Edited Support: renamed it Helpdesk, changed the description, added David Kim.
    • Granted, then revoked, Read users on Billing.
    • After a reload each change was still there, and the audit log listed the Granted and Revoked rows by Test Admin at the top (9 entries).
  • npx tsc --noEmit, which also type-checks e2e/, npx eslint src/ --max-warnings 0, prettier on the changed files, bun run test:lint-rules, npx vitest run (154 files, 3338 tests) and bun run build all pass. ESLint's config covers only src/, so the e2e/ files are type-checked and formatted but not linted.

Test Configuration:

  • Mock: E2E_ADMIN_EMAIL=admin@test.com E2E_ADMIN_PASSWORD=password npx playwright test e2e/access.spec.ts e2e/grants.spec.ts --workers=2. This starts vite dev and e2e/mock-backend.mjs.
  • Live: E2E_LIVE=1 E2E_BASE_URL=<panel> E2E_ADMIN_EMAIL=<admin> E2E_ADMIN_PASSWORD=<password> npx playwright test e2e/access.spec.ts e2e/grants.spec.ts --workers=2, against a production build of the panel and a LibreChat dev backend on a fresh database.
  • Playwright 1.61.

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my code
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective or that my feature works
  • Local unit tests pass with my changes

…kend

Since #185 the /_app route loads GET /api/admin/grants/effective before the
panel renders, and fails closed when it can't. e2e/mock-backend.mjs never
served that route, so every signed-in page in the mock e2e run showed "Could
not verify permissions". The mock now answers it for its admin tokens with
every capability in SystemCapabilities, refusing other requests the way it
refuses /api/admin/verify; both share one token check.
…k and Update the Access and Grants Specs

e2e/mock-backend.mjs served none of the admin API routes the Access and
Grants pages read, so most of access.spec.ts and grants.spec.ts failed
against it. The mock now holds in-memory fixtures (users, the ADMIN and USER
roles plus Auditor, Billing and Support, the Engineering and Marketing
groups, role grants and a hash-chained grant history) and answers the
roles, groups, user search, grants and audit-log routes in LibreChat's
response shapes. Group search is LibreChat's case-insensitive substring
match on name, email and description; user search matches word prefixes.
Writes change the fixtures, so create, edit and delete flows work. Path
parameters are matched per route and every admin route shares the existing
token check.

The specs also asserted UI the panel does not have: title-cased role names,
a searchbox role, kebab menus, keyboard reordering, a roles/groups type
filter and user grants. They now follow the current panel, wait for
filtered results instead of sleeping, and delete the roles and groups they
create. Under E2E_LIVE=1 the Groups-tab tests create the groups they list
and delete them afterwards, and the tests that read users or grant history
only the mock has are skipped.
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 1. Head: 39ee16a

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T16:04:05.230060Z cf7f36b Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 39ee16a958

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread e2e/access.spec.ts Resolved
Comment thread e2e/mock-backend.mjs Resolved
Comment thread e2e/mock-backend.mjs Resolved
… CSV Export From the Mock and Test That a Role Rename Leaves Its Grants Behind
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 2. Head: ecb944b

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ecb944b42d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread e2e/access.spec.ts Resolved
Comment thread e2e/access.spec.ts Resolved
Comment thread e2e/grants.spec.ts Resolved
Comment thread e2e/access.spec.ts Resolved
…he Mock and Find Staged Groups Past the First Page
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 3. Head: 6727de9

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6727de9ca8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread e2e/mock-backend.mjs Resolved
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 4. Head: cf7f36b

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: cf7f36b1eb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila
danny-avila changed the base branch from pr/e2e-mock-effective to main October 28, 2026 07:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant