Task Issue 1760 User Role Management Session Expired Bounce - sebiboga/testlink-upgraded GitHub Wiki
Task, Issue #1760 β User & Role Management: bounce to login on a 401 session_expired
Screens: gui/templates/usermanagement/usersView.html (User Management),
gui/templates/usermanagement/rolesView.html (Role Management)
BFF: api/users/index.php (changed), api/roles/index.php (already correct)
Status: implemented, verified, pushed on task/issue-1760
The gap β it was TWO layers, not one
Legacy 1.9.20 ran testlinkInitPage() on every page, which called checkSessionValid()
(lib/functions/common.php:281-310): a session idle longer than
config_get("sessionInactivityTimeout") minutes was redirected to
login.php?note=expired&destination=<this screen> via redirect(..., "top.location")
before the first render, so no user/role screen ever painted a form against a dead session β
and no write could be committed.
The issue body listed only the front-end half of the gap. The measurement found more:
Layer 1 β api/users/index.php never enforced the inactivity window
api/_guard.php:175-198 bffEnforceSession() existed and was called by api/roles/index.php:40
after the userID gate and before any route dispatch, but the Users BFF never called it.
doSessionStart() alone does not enforce the window, so a tab left open past the timeout kept
serving the user catalog and kept writing users:
-- STALE session (lastActivity = now-700000; window = 9900 min = 594000 s) --
GET /api/users/index.php -> 200 {"status":"ok","items":[β¦adminβ¦]}
GET /api/users/index.php/meta/grants -> 200 {"status":"ok","grants":{β¦}}
POST /api/users/index.php (create) -> 200 {"status":"ok","item":{"id":2,"login":"stale_probe"}}
select id,login from users; -> 1 admin, 2 stale_probe <-- the row was really written
while the sibling Roles BFF already answered 401 {"code":"session_expired"} for the same stale
session. Fix: one call, bffEnforceSession($db);, placed like the Roles one.
Layer 2 β neither screen inspected the 401 status
| screen | request paths | before |
|---|---|---|
usersView.html |
12 | 401 β deny box "mgt_users right required" (path 1), silently swallowed (2β6), alert "login does not exist" (7), alert/toast "the write failed" (8β12) |
rolesView.html |
8 | 401 β deny box "role_management right required" (3), swallowed (1, 2, 4), false write-failure messages (5, 7, 8), opened the delete-confirm dialog with an armed Delete button (6) |
A stale session therefore produced a rights diagnosis that did not exist, and a write-failure diagnosis for writes that were never attempted. Legacy could show none of them.
The fix
api/users/index.php β bffEnforceSession($db); after the userID gate, before the mgt_users
right check and before any route dispatch (covers the four /meta/* reads, the list, ?login=,
/{id}, POST, PUT, DELETE, reset-password, generate-apikey).
usersView.html / rolesView.html β the proven helper set from
usersAssignProject.html:325-396 (issues #1614/#1620, usersAssignPlan.html:579-613 #1646):
sessionExpired(xhr)β keys on the HTTP status (401) with the bodycode === 'session_expired'as a secondary trigger. Status-first is mandatory: an absent session answers401 {"status":"error","message":"Not authenticated"}with nocodekey at all (api/users/index.php:27,api/roles/index.php:26), and a code-only test would have let that fall through into the deny box. Shows the localizedauth.sessionExpiredtoast as an error, then re-arms a single 600 mswindow.topbounce tologin.php?note=expired&destination=<path + query>(the Dashio shell lives in an iframe, so only the top window is redirected β the stale shell and its menu go with it).showSessionExpired()β terminal state: hides the demo banner, tab bar and every interactive block (toolbar, grid toolbar, table, footers), closes the modal(s) β including the role delete-confirm dialog whose Delete button was really clickable β destroys the DataTable, emptiestbodyand clears the cached model (allItems/roleMap/localeMap/authMeta/rightsMap/allRights/canViewEvents). No deny box: a rights message would send the user chasing a configuration problem they do not have.sessionDeadβ entry guard in every loader and every action function, plus aif (sessionDead) return;in every success callback that could repaint the neutralised screen from a request that was in flight when the 401 landed (and inloadMeta's.always(), which used to kick off the user-list load even after a 401).
403 handling is untouched on both screens: a rights failure still shows the deny box.
No i18n change β auth.sessionExpired already exists in all ten bundles (de, en, es, fr, it,
ja, pt, ro, ru, zh).
Verification
Stale session = age the session file's lastActivity to now-700000 (window 594 000 s; ageing by
only β100 000 s still returns 200 because every valid call slides lastActivity forward).
API (tmp/repro_1760.sh)
| request | stale, before | stale, after | valid session |
|---|---|---|---|
GET /api/users/index.php |
200 + user list |
401 {"code":"session_expired"} |
200 |
GET /api/users/index.php/meta/grants |
200 |
401 |
200 |
POST /api/users/index.php (create) |
200, row written |
401, table unchanged |
200 |
GET /api/roles/index.php, /meta/rights |
401 |
401 |
200 |
| no cookie, either BFF | 401 (no code) |
401 (no code) |
β |
Screen (headless Chrome, real stale session)
usersView.html?tproject_id=1&tplan_id=0βlogin.php?note=expired&destination=%2Fgui%2Ftemplates%2Fusermanagement%2FusersView.html%3Ftproject_id%3D1%26tplan_id%3D0, login page shows "Session expired. Please log in again."rolesView.html?tproject_id=1&tplan_id=0β same bounce, same note.
Helper matrix (both screens, in-page)
| input | result |
|---|---|
{status:401, body {code:session_expired}} / {401, empty body} / {401, HTML body} / {401, no code} / {200, code:session_expired} |
true |
{status:403} / {200} / {500} / null |
false (403 β deny box preserved) |
after a true: toast = "Session expired. Please log in again." (.err), tab bar / toolbar / table hidden, deny box hidden, 0 rows, model cleared, all loaders + action functions inert |
verified on both screens |
Regression
- valid session:
usersView2 rows + 4 tabs,rolesView9 roles, no deny box β unchanged. - 403 path (
norights/ role 3, fixture fromtmp/mkuser_norights.php): deny box shown, no bounce,sessionDead === false. select count(*) from events where log_level <> 16β 0; browser console: only the expected 403 network log, no JS errors.php -l api/users/index.phpclean;node --checkon both extracted inline scripts clean.
Code-review hardening (post-review, verified)
sessionExpired()made idempotent βif (sessionDead) { return true; }after the detection: three concurrent 401s (the screen fires 5 requests on load) all answertrue, so no caller falls into its own error branch, while the toast and the navigation timer fire exactly once.if (sessionDead) { return; }added as the first statement of every$.ajaxsuccess handler (5 inusersView.html, 3 inrolesView.html) so a response that raced the bounce cannot repaint or re-open a modal over the neutralised screen.- No new i18n key: the only new user-visible string is the pre-existing
TLi18n.t('auth.sessionExpired').
Suite 1760 in tmp/TLU_Test_Cases.md: 26/26 PASS.
