Task Issue 1620 Assign Project Roles Initial Load Session Expired - sebiboga/testlink-upgraded GitHub Wiki

Task, Issue #1620 — Assign Test Project Roles: bounce to login on a 401 session_expired on the first request

Screen: gui/templates/usermanagement/usersAssignProject.html (Assign Test Project Roles) BFF: api/roles/index.php (no change needed — bffEnforceSession() already answers 401, #1614) Status: implemented, verified, pushed on task/issue-1620

The gap

Legacy 1.9.20 opened the screen through lib/usermanagement/usersAssign.php:23 testlinkInitPage($db,false,false,"checkRights"), which runs checkSessionValid($db) (lib/functions/common.php:531-533 → :281-310) before anything is rendered. A session idle for more than config_get("sessionInactivityTimeout") minutes therefore never rendered the assignment form at all: the controller logged INFO and did redirect("login.php?note=expired&destination=<this screen>","top.location") + exit() (common.php:295-308). The controller ran the same check again right after the update block (usersAssign.php:97).

The modern BFF cannot redirect, so the guard answers instead (api/_guard.php:175-198 bffEnforceSession(), called at api/roles/index.php:40, after the userID gate and before any route dispatch):

HTTP/1.1 401  {"status":"error","code":"session_expired","message":"session_expired"}

usersAssignProject.html already turned that into the legacy bounce — but only on two of the three request paths it makes:

path handler on a 401
project combo (FIRST request of the page) loadProjects().fail() was not handled — fell into the generic else and painted the empty-state box
user grid loadUsers().fail() sessionExpired(xhr) → bounce (#1614)
save saveAssignments() sessionExpired(xhr) → bounce (#1614)

loadProjects() is called first, from the TLi18n.load callback at :364, so it is exactly the request legacy guarded pre-render. Its .fail() only branched on 403:

.fail(function(xhr) {
  if (xhr.status === 403) { showNoAccess(); }
  else { $('#emptyMsg').show(); }        // <-- the 401 landed here
});

Measured dead state (aged session + reload, this run):

url               : …/usersAssignProject.html?tproject_id=2   (no bounce)
.toolbar display  : flex                 combo: 0 options, value ""
#emptyMsg         : block  "Select a test project above to manage role assignments."
#toast            : ""                   #saveBtn.disabled: true (permanently)
#tabsBar          : 4 tabs, all of them dead

The user was left staring at a form that can never load anything, told to "select a test project" from an empty combo, with no message explaining why. This is the normal way the state is reached: the Dashio shell itself never refreshes the timer (index.php calls only doSessionStart(), no checkSessionValid()), so a shell tab left open past the timeout is still rendered and its aside link loads this screen straight into the dead state.

The fix

gui/templates/usermanagement/usersAssignProject.html

  1. loadProjects().fail() calls sessionExpired(xhr) first, before the 403/generic branches — the same shape as loadUsers() and saveAssignments().
  2. New showSessionExpired() — the terminal state helper, modelled on the existing showNoAccess() / showDisabled(): hides the demo banner, the tab bar and the toolbar, empties and disables the project combo, destroys the DataTables instance and clears assignTbl, currentItems, roleChoices and changedMap (the four pieces loadUsers() resets together), hides the grid and all three message boxes (#emptyMsg, #disabledMsg, #denyBox), hides the demo note, disables Save and empties the footer. sessionExpired() calls it before the toast and the 600 ms redirect, so the 600 ms cannot leave a live toolbar / combo / Save button on screen. Legacy redirected before the first render, so "no assignment form at all" is the parity target; the tab bar goes with it because every tab link loads a screen that would 401 identically.
  3. The #projectSelect change handler bails out while the combo is disabled, so the neutralised state can never re-arm a request against the dead session (and a combo emptied while a previous load is in flight cannot wipe a still-valid grid — the same trap already fixed in that handler by the #1707 review).
  4. The sessionExpired() header comment no longer claims the bounce only covers "the initial user list load and a Save" — it now lists all three request paths.

Hardening added by the code review (same run)

finding change
a request already in flight when the 401 landed could repaint the neutralised state from its late .done() (re-created the DataTable, re-populated the footer) sessionDead flag set by showSessionExpired(), checked at the top of loadProjects() / loadUsers() and in both .done() callbacks — the terminal state is now authoritative
two requests 401-ing in the same tick stacked two setTimeout redirects one module-level redirectTimer, clearTimeout before re-arming → exactly one navigation
sessionExpired() threw on a null argument (the sibling screen guards it) if (!xhr) return false;
showSessionExpired() cleared currentItems but left roleChoices / changedMap behind both reset with it
the change handler read $(this).val() before its guard (dead store) guard moved first

No i18n change: the fix reuses auth.sessionExpired, already present in all ten locale bundles (de, en, es, fr, it, ja, pt, ro, ru, zh) and already used by the two older paths. No BFF change: bffEnforceSession() was already correct.

Verification

Reproduce a stale session by ageing the stored lastActivity ($tlCfg->sessionInactivityTimeout = 9900 minutes → 594 000 s, so ageing by -100000 s still returns 200 — every valid call slides it forward, common.php:286-288; age by -700000 s):

SF=$(sudo ls -t /var/lib/php/sessions/ | head -1)   # re-read: a bounce mints a NEW PHPSESSID
sudo sed -i "s/lastActivity|i:[0-9]*/lastActivity|i:$(( $(date +%s) - 700000 ))/" /var/lib/php/sessions/$SF
check result
first request (loadProjects) 401 bounces to login.php?note=expired&destination=%2Fgui%2Ftemplates%2Fusermanagement%2FusersAssignProject.html%3Ftproject_id%3D2, login page shows "Session expired. Please log in again."
page state during the 600 ms window tabs + toolbar hidden, combo empty + disabled, all 3 message boxes hidden, Save disabled, footer empty, DataTables destroyed; a change event on the disabled combo issues 0 requests
project switch (loadUsers) 401 still bounces
save 401 still bounces
stubbed 403 on the first request deny box "You do not have enough rights to access this feature.", no toast, no redirect — the branch below the new early-return is intact
sessionExpired({status:403}) / {status:500} false, no side effects — no false positive
healthy session 2 rows, combo populated, toolbar flex, 4 tabs, Save disabled until a change — unchanged
user empties the combo grid cleared + #emptyMsg shown — the disabled-combo guard does not swallow it
Event Viewer / logs browser console: 0 error+warning; grep -icE "PHP (Warning|Notice|Fatal|Deprecated)" logs/userlog0.log logs/userlog1.log → 0 / 0; newest events rows are log_level 16 audit_login_succeeded (own logins)
node --check on the inline script OK

Suite 1620 in tmp/TLU_Test_Cases.md: 10 PASS / 0 FAIL / 1 not exercisable (the showDisabled() branch needs a caller with no assignable project; documented, not claimed).

Fixtures used (freshly imported DB has no test project, so the form was unreachable): test projects #2 "Issue 1620 Project" / #3 "Issue 1620 Project B" (public) plus user #2 tester1620 (global role 7).

Note for the other screens

Of the two assign-roles screens this was the last one missing the 401 branch on its first request — usersAssignPlan.html got the full four-path treatment in #1646. The neighbouring usersView.html and rolesView.html (same ASIDE section, same tab bar, 12 and 8 request paths) still contain no 401 handling at all and are filed as a follow-up task; when porting this helper to a new screen, check every request path, not just the one that renders the grid.

Screenshots

Healthy state (fresh session, 2 users, combo populated):

healthy

The dead screen this issue is about — aged session, first request answered 401 {"code":"session_expired"}: toolbar still interactive, EMPTY combo, "Select a test project above to manage role assignments.", no toast, Save permanently disabled, no bounce:

dead screen before

After the fix — the same aged session now bounces to the login page with the localized "Session expired. Please log in again." box and the screen in destination:

session expired bounce

The neutralised state the screen shows during the 600 ms before the redirect (produced by calling showSessionExpired() directly, so it can be photographed):

neutralised state