feat(260606-tv8-01): add one-shot login-redirect helper + tests; fix client.ts comment
- Add loginRedirect.ts: maybeRedirectToLogin (sessionStorage one-shot guard) and clearLoginRedirect; guards window/sessionStorage for SSR/test safety - Add loginRedirect.test.ts: covers first-call redirect, one-shot no-op, clear+retry - Update client.ts: remove false claim that fetch follows Authelia 302 automatically; note that XHR/fetch CORS-blocks cross-origin redirects, top-level nav required
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* loginRedirect — one-shot sessionStorage-guarded redirect helper.
|
||||
*
|
||||
* Covers:
|
||||
* - maybeRedirectToLogin() redirects to /api/login on first call and returns true
|
||||
* - maybeRedirectToLogin() does NOT redirect on second call (one-shot guard) and returns false
|
||||
* - clearLoginRedirect() removes the flag so a subsequent call redirects again
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// Stub window.location with a writable href before importing the module.
|
||||
// jsdom sets window.location to a read-only getter backed by a Location object;
|
||||
// we need to replace it with a plain object so href assignment is detectable.
|
||||
const locationStub = { href: '' }
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: locationStub,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
import { maybeRedirectToLogin, clearLoginRedirect } from './loginRedirect.js'
|
||||
|
||||
describe('maybeRedirectToLogin', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
locationStub.href = ''
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('sets window.location.href to /api/login and returns true on first call', () => {
|
||||
const result = maybeRedirectToLogin()
|
||||
expect(result).toBe(true)
|
||||
expect(locationStub.href).toBe('/api/login')
|
||||
})
|
||||
|
||||
it('sets the sessionStorage flag after first call', () => {
|
||||
maybeRedirectToLogin()
|
||||
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBe('1')
|
||||
})
|
||||
|
||||
it('does NOT change href on second call and returns false (one-shot guard)', () => {
|
||||
maybeRedirectToLogin() // first call — redirects
|
||||
locationStub.href = '' // reset the stub to detect a second assignment
|
||||
const result = maybeRedirectToLogin() // second call — should NOT redirect
|
||||
expect(result).toBe(false)
|
||||
expect(locationStub.href).toBe('') // href must NOT have been reassigned
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearLoginRedirect', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
locationStub.href = ''
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('removes the sessionStorage flag', () => {
|
||||
sessionStorage.setItem('familysync.loginRedirectAttempted', '1')
|
||||
clearLoginRedirect()
|
||||
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows maybeRedirectToLogin to redirect again after the flag is cleared', () => {
|
||||
maybeRedirectToLogin() // first call — sets flag
|
||||
clearLoginRedirect() // clear the flag
|
||||
locationStub.href = '' // reset stub
|
||||
const result = maybeRedirectToLogin() // should redirect again
|
||||
expect(result).toBe(true)
|
||||
expect(locationStub.href).toBe('/api/login')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* One-shot login-redirect helper.
|
||||
*
|
||||
* Problem: the OIDC guard issues a 302 to Authelia, but fetch()/XHR cannot follow a
|
||||
* cross-origin 302 to Authelia — the browser enforces CORS on XHR redirects, so the
|
||||
* response is opaque and the SPA never reaches Authelia. Re-auth requires a TOP-LEVEL
|
||||
* browser navigation to /api/login, where the guard's redirect is followed at the
|
||||
* document level (no CORS restriction).
|
||||
*
|
||||
* Solution: when /api/me fails (indicating an unauthenticated session), perform a
|
||||
* full-page navigation to /api/login. A sessionStorage flag prevents infinite loops:
|
||||
* if the navigation completes and /api/me still fails (genuine error), the second
|
||||
* maybeRedirectToLogin() call is a no-op and the "Sign-in required" UI is shown.
|
||||
*/
|
||||
|
||||
export const LOGIN_REDIRECT_KEY = 'familysync.loginRedirectAttempted'
|
||||
|
||||
/**
|
||||
* Navigate to /api/login if this is the first attempt.
|
||||
*
|
||||
* Returns true when a redirect was triggered (caller can bail early — the page
|
||||
* is navigating away). Returns false when the flag was already set (already
|
||||
* bounced through login once; let the caller render the error UI instead).
|
||||
*
|
||||
* Safe under SSR/test environments: no-ops and returns false when window or
|
||||
* sessionStorage is unavailable.
|
||||
*/
|
||||
export function maybeRedirectToLogin(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (typeof sessionStorage === 'undefined') return false
|
||||
|
||||
try {
|
||||
if (sessionStorage.getItem(LOGIN_REDIRECT_KEY) !== null) {
|
||||
// Already attempted — do not redirect again (loop guard).
|
||||
return false
|
||||
}
|
||||
sessionStorage.setItem(LOGIN_REDIRECT_KEY, '1')
|
||||
window.location.href = '/api/login'
|
||||
return true
|
||||
} catch {
|
||||
// sessionStorage access can throw in private-browsing mode or with storage quota
|
||||
// exceeded. Fail open: do not redirect, let the caller render the error.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the one-shot flag.
|
||||
*
|
||||
* Call on a successful /api/me load so that a later session expiry can trigger
|
||||
* another redirect instead of silently showing "Sign-in required".
|
||||
*
|
||||
* Safe under SSR/test environments (guarded).
|
||||
*/
|
||||
export function clearLoginRedirect(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof sessionStorage === 'undefined') return
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(LOGIN_REDIRECT_KEY)
|
||||
} catch {
|
||||
// Ignore storage errors — clearing the flag is best-effort.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user