Stop treating transient auth_required as home-session expiry, keep auth scope consistent across login redirect and server-rail joins, and leave /login when the in-memory user is still authenticated. Co-authored-by: Cursor <cursoragent@cursor.com>
144 lines
4.9 KiB
TypeScript
144 lines
4.9 KiB
TypeScript
import { firstValueFrom, of } from 'rxjs';
|
|
import {
|
|
describe,
|
|
expect,
|
|
it
|
|
} from 'vitest';
|
|
|
|
import { UsersActions } from '../../../../store/users/users.actions';
|
|
import {
|
|
buildLoginReturnQueryParams,
|
|
resolveSafeReturnUrl,
|
|
resolveSessionExpiredNavigation,
|
|
resolveUnauthenticatedStartupRedirect,
|
|
waitForAuthenticationOutcome
|
|
} from './auth-navigation.rules';
|
|
|
|
describe('resolveSafeReturnUrl', () => {
|
|
it('returns the requested in-app path unchanged', () => {
|
|
expect(resolveSafeReturnUrl('/servers')).toBe('/servers');
|
|
expect(resolveSafeReturnUrl('/room/abc')).toBe('/room/abc');
|
|
});
|
|
|
|
it('unwraps nested login returnUrl chains to the original destination', () => {
|
|
const nested = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
|
|
|
|
expect(resolveSafeReturnUrl(nested)).toBe('/servers');
|
|
expect(resolveSafeReturnUrl(`/login?returnUrl=${encodeURIComponent(nested)}`)).toBe('/servers');
|
|
});
|
|
|
|
it('falls back to dashboard for auth-only return targets', () => {
|
|
expect(resolveSafeReturnUrl('/login')).toBe('/dashboard');
|
|
expect(resolveSafeReturnUrl('/register')).toBe('/dashboard');
|
|
expect(resolveSafeReturnUrl(null)).toBe('/dashboard');
|
|
});
|
|
|
|
it('rejects open redirects and protocol-relative paths', () => {
|
|
expect(resolveSafeReturnUrl('//evil.example/phish')).toBe('/dashboard');
|
|
expect(resolveSafeReturnUrl('https://evil.example/phish')).toBe('/dashboard');
|
|
});
|
|
});
|
|
|
|
describe('buildLoginReturnQueryParams', () => {
|
|
it('preserves a safe destination when redirecting from protected routes', () => {
|
|
expect(buildLoginReturnQueryParams('/servers')).toEqual({ returnUrl: '/servers' });
|
|
});
|
|
|
|
it('does not nest login returnUrl values', () => {
|
|
expect(buildLoginReturnQueryParams('/login?returnUrl=%2Fservers')).toEqual({ returnUrl: '/servers' });
|
|
expect(buildLoginReturnQueryParams('/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers')).toEqual({
|
|
returnUrl: '/servers'
|
|
});
|
|
});
|
|
|
|
it('omits returnUrl when there is no meaningful destination', () => {
|
|
expect(buildLoginReturnQueryParams('/login')).toEqual({});
|
|
expect(buildLoginReturnQueryParams('/register')).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('resolveUnauthenticatedStartupRedirect', () => {
|
|
it('sends signed-out visitors on the dashboard/root to login (no mobile exception)', () => {
|
|
expect(resolveUnauthenticatedStartupRedirect('/dashboard')).toEqual({
|
|
path: '/login',
|
|
queryParams: {}
|
|
});
|
|
|
|
expect(resolveUnauthenticatedStartupRedirect('/')).toEqual({
|
|
path: '/login',
|
|
queryParams: { returnUrl: '/' }
|
|
});
|
|
});
|
|
|
|
it('preserves a safe returnUrl when redirecting from a protected route', () => {
|
|
expect(resolveUnauthenticatedStartupRedirect('/servers')).toEqual({
|
|
path: '/login',
|
|
queryParams: { returnUrl: '/servers' }
|
|
});
|
|
|
|
expect(resolveUnauthenticatedStartupRedirect('/room/abc')).toEqual({
|
|
path: '/login',
|
|
queryParams: { returnUrl: '/room/abc' }
|
|
});
|
|
});
|
|
|
|
it('leaves public auth/invite routes untouched', () => {
|
|
expect(resolveUnauthenticatedStartupRedirect('/login')).toBeNull();
|
|
expect(resolveUnauthenticatedStartupRedirect('/login?returnUrl=%2Fservers')).toBeNull();
|
|
expect(resolveUnauthenticatedStartupRedirect('/register')).toBeNull();
|
|
expect(resolveUnauthenticatedStartupRedirect('/invite/abc123')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolveSessionExpiredNavigation', () => {
|
|
const currentUser = { id: 'user-1' };
|
|
|
|
it('keeps an authenticated user on protected routes', () => {
|
|
expect(resolveSessionExpiredNavigation(currentUser, '/room/abc')).toEqual({ kind: 'stay' });
|
|
});
|
|
|
|
it('leaves the login page when the in-memory user is still authenticated', () => {
|
|
expect(resolveSessionExpiredNavigation(currentUser, '/login?returnUrl=%2Fservers')).toEqual({
|
|
kind: 'leave-auth-route',
|
|
returnUrl: '/servers'
|
|
});
|
|
});
|
|
|
|
it('sends fully signed-out users to login with a safe returnUrl', () => {
|
|
expect(resolveSessionExpiredNavigation(null, '/room/abc')).toEqual({
|
|
kind: 'navigate-login',
|
|
queryParams: { returnUrl: '/room/abc' }
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('waitForAuthenticationOutcome', () => {
|
|
it('resolves when authentication storage preparation succeeds', async () => {
|
|
const user = {
|
|
id: 'user-1',
|
|
oderId: 'user-1',
|
|
username: 'alice',
|
|
displayName: 'Alice',
|
|
status: 'online' as const,
|
|
role: 'member' as const,
|
|
joinedAt: 1
|
|
};
|
|
const outcome = await firstValueFrom(waitForAuthenticationOutcome(of(
|
|
UsersActions.setCurrentUser({ user })
|
|
)));
|
|
|
|
expect(outcome).toEqual({ kind: 'success', user });
|
|
});
|
|
|
|
it('resolves with a failure when authentication storage preparation fails', async () => {
|
|
const outcome = await firstValueFrom(waitForAuthenticationOutcome(of(
|
|
UsersActions.loadCurrentUserFailure({ error: 'Failed to prepare local user state.' })
|
|
)));
|
|
|
|
expect(outcome).toEqual({
|
|
kind: 'failure',
|
|
error: 'Failed to prepare local user state.'
|
|
});
|
|
});
|
|
});
|