1818 * `LoginRedirect`), and the real Console `LoginPage` with its post-login
1919 * hand-off. Only the shell's provider stack is a pass-through.
2020 *
21- * - A signs out of a list carrying every kind of list state; the login page's
22- * URL carries none of it, and B lands on the same path with the other
23- * params kept (the card's reproduction, steps 4 to 7);
21+ * - A's session expires on a list carrying every kind of list state; the
22+ * login page's URL carries none of it, and B lands on the same path with
23+ * the other params kept (the card's reproduction, steps 4 to 7, with the
24+ * session ending by expiry: an explicit sign-out now carries no return link
25+ * at all, pinned in the objectstack#30125 block below);
2426 * - an invitation redirect still lands on the invitation;
2527 * - a signed-out arrival on a deep link with no list state keeps it byte for
2628 * byte;
3436
3537import '@testing-library/jest-dom/vitest' ;
3638import { describe , it , expect , vi , beforeEach , afterEach } from 'vitest' ;
37- import { render , screen , waitFor , cleanup } from '@testing-library/react' ;
39+ import { render , screen , waitFor , cleanup , act } from '@testing-library/react' ;
3840import userEvent from '@testing-library/user-event' ;
39- import { MemoryRouter , Routes , Route , useLocation } from 'react-router-dom' ;
40- import type { ReactNode } from 'react' ;
41+ import { MemoryRouter , Routes , Route , useLocation , useNavigate } from 'react-router-dom' ;
42+ import { useEffect , type ReactNode } from 'react' ;
4143import { AuthProvider , useAuth } from '@object-ui/auth' ;
4244import type { AuthClient } from '@object-ui/auth' ;
4345
@@ -100,24 +102,43 @@ function createClient(): AuthClient {
100102
101103/** Every location the router settles on, oldest first. */
102104const seen : string [ ] = [ ] ;
105+ /** The router's own navigate, for an in-app move between sign-ins. */
106+ const navigateTo : { current : ( ( to : string ) => void ) | null } = { current : null } ;
103107function LocationProbe ( ) {
104108 const location = useLocation ( ) ;
109+ const navigate = useNavigate ( ) ;
110+ useEffect ( ( ) => {
111+ navigateTo . current = navigate ;
112+ } , [ navigate ] ) ;
105113 seen . push ( location . pathname + location . search ) ;
106114 return null ;
107115}
108116
109- /** The list page, reduced to the account menu's sign-out. */
117+ /**
118+ * The list page, reduced to the account menu's sign-out and a session
119+ * re-resolution: the provider's own loader, which is how a session that
120+ * expired or was revoked under the page reaches it.
121+ */
110122function SignedInList ( ) {
111- const { user, signOut } = useAuth ( ) ;
123+ const { user, signOut, refreshSession } = useAuth ( ) ;
112124 return (
113125 < div data-testid = "list-page" data-user = { user ?. id ?? '' } >
114126 < button type = "button" onClick = { ( ) => void signOut ( ) } >
115127 Sign out
116128 </ button >
129+ < button type = "button" onClick = { ( ) => void refreshSession ( ) } >
130+ Re-check session
131+ </ button >
117132 </ div >
118133 ) ;
119134}
120135
136+ /** The server ends the session on its own (expiry, revocation), and the page learns it. */
137+ async function expireSession ( ) {
138+ serverUser = null ;
139+ await userEvent . setup ( ) . click ( screen . getByRole ( 'button' , { name : 'Re-check session' } ) ) ;
140+ }
141+
121142function renderConsole ( entry : string , client : AuthClient ) {
122143 return render (
123144 < AuthProvider authUrl = "/api/v1/auth" client = { client } >
@@ -176,7 +197,7 @@ afterEach(() => {
176197} ) ;
177198
178199describe ( 'signing out leaves the list state behind (objectstack#29174)' , ( ) => {
179- it ( 'A signs out of a filtered, searched, sorted list; B signs in and lands on the path with none of it' , async ( ) => {
200+ it ( "A's session expires on a filtered, searched, sorted list; B signs in and lands on the path with none of it" , async ( ) => {
180201 serverUser = USER_A ;
181202 const client = createClient ( ) ;
182203 renderConsole ( A_LIST_URL , client ) ;
@@ -185,8 +206,8 @@ describe('signing out leaves the list state behind (objectstack#29174)', () => {
185206 expect ( ( await screen . findByTestId ( 'list-page' ) ) . dataset . user ) . toBe ( USER_A . id ) ;
186207 expect ( seen [ seen . length - 1 ] ) . toBe ( A_LIST_URL ) ;
187208
188- await userEvent . setup ( ) . click ( screen . getByRole ( 'button' , { name : 'Sign out' } ) ) ;
189- expect ( client . signOut ) . toHaveBeenCalledTimes ( 1 ) ;
209+ await expireSession ( ) ;
210+ expect ( client . signOut ) . not . toHaveBeenCalled ( ) ;
190211
191212 // The login page's own URL carries none of it: not the params, not a value.
192213 const { url, redirect } = await loginRedirect ( ) ;
@@ -236,3 +257,70 @@ describe('signing out leaves the list state behind (objectstack#29174)', () => {
236257 expect ( redirect ) . toBe ( `${ LIST_PATH } ?recordId=r1` ) ;
237258 } ) ;
238259} ) ;
260+
261+ /**
262+ * objectstack#30125 — after a sign-out, the next person lands on the previous
263+ * person's page. A new member's first screen was "you do not have access to
264+ * this app" for the Setup page the administrator had open.
265+ *
266+ * An explicit sign-out sends the visitor to `/login` with no return link, so
267+ * whoever signs in next lands on their own home. A session that ends under the
268+ * person (expired, revoked) still keeps the path, so that person signs back in
269+ * where they were. Same real composition as the block above.
270+ */
271+ describe ( 'an explicit sign-out leaves no return link (objectstack#30125)' , ( ) => {
272+ const SETUP_PAGE = '/apps/com.objectstack.setup/sys_user/view/all?recordId=u1' ;
273+
274+ it ( 'A signs out of a Setup page; B signs in and lands on home, not on that page' , async ( ) => {
275+ serverUser = USER_A ;
276+ const client = createClient ( ) ;
277+ renderConsole ( SETUP_PAGE , client ) ;
278+ expect ( ( await screen . findByTestId ( 'list-page' ) ) . dataset . user ) . toBe ( USER_A . id ) ;
279+
280+ await userEvent . setup ( ) . click ( screen . getByRole ( 'button' , { name : 'Sign out' } ) ) ;
281+ expect ( client . signOut ) . toHaveBeenCalledTimes ( 1 ) ;
282+
283+ const { url, redirect } = await loginRedirect ( ) ;
284+ expect ( url ) . toBe ( '/login' ) ;
285+ expect ( redirect ) . toBeNull ( ) ;
286+
287+ await signInAs ( USER_B . email ) ;
288+ expect ( await landing ( ) ) . toBe ( withConsoleBase ( '/' ) ) ;
289+ } ) ;
290+
291+ it ( "A's session expires on the same page; A signs back in and lands on it" , async ( ) => {
292+ serverUser = USER_A ;
293+ const client = createClient ( ) ;
294+ renderConsole ( SETUP_PAGE , client ) ;
295+ expect ( ( await screen . findByTestId ( 'list-page' ) ) . dataset . user ) . toBe ( USER_A . id ) ;
296+
297+ await expireSession ( ) ;
298+
299+ const { url, redirect } = await loginRedirect ( ) ;
300+ expect ( url ) . toBe ( `/login?redirect=${ encodeURIComponent ( SETUP_PAGE ) } ` ) ;
301+ expect ( redirect ) . toBe ( SETUP_PAGE ) ;
302+
303+ await signInAs ( USER_A . email ) ;
304+ expect ( await landing ( ) ) . toBe ( withConsoleBase ( SETUP_PAGE ) ) ;
305+ } ) ;
306+
307+ it ( 'the sign-out is forgotten once someone signs in: a later expiry keeps the path again' , async ( ) => {
308+ serverUser = USER_A ;
309+ const client = createClient ( ) ;
310+ renderConsole ( SETUP_PAGE , client ) ;
311+ expect ( ( await screen . findByTestId ( 'list-page' ) ) . dataset . user ) . toBe ( USER_A . id ) ;
312+
313+ // A signs out; B signs in on the same tab, without a page load.
314+ await userEvent . setup ( ) . click ( screen . getByRole ( 'button' , { name : 'Sign out' } ) ) ;
315+ expect ( ( await loginRedirect ( ) ) . redirect ) . toBeNull ( ) ;
316+ await signInAs ( USER_B . email ) ;
317+ await landing ( ) ;
318+
319+ // B opens the page in-app, and B's session then expires under it.
320+ act ( ( ) => navigateTo . current ! ( SETUP_PAGE ) ) ;
321+ expect ( ( await screen . findByTestId ( 'list-page' ) ) . dataset . user ) . toBe ( USER_B . id ) ;
322+ await expireSession ( ) ;
323+
324+ expect ( ( await loginRedirect ( ) ) . redirect ) . toBe ( SETUP_PAGE ) ;
325+ } ) ;
326+ } ) ;
0 commit comments