import { describe, it, expect } from 'vitest'; import { safeNext } from './safeNext'; describe('safeNext', () => { it('allows same-origin absolute paths', () => { expect(safeNext('/library')).toBe('/library'); expect(safeNext('/search?q=berserk')).toBe('/search?q=berserk'); expect(safeNext('/manga/123#page-2')).toBe('/manga/123#page-2'); }); it('falls back to / for missing or empty input', () => { expect(safeNext(null)).toBe('/'); expect(safeNext(undefined)).toBe('/'); expect(safeNext('')).toBe('/'); }); it('rejects protocol-relative URLs (open redirect)', () => { expect(safeNext('//evil.com')).toBe('/'); expect(safeNext('//evil.com/path')).toBe('/'); }); it('rejects backslash tricks browsers normalise to //', () => { expect(safeNext('/\\evil.com')).toBe('/'); expect(safeNext('/\\/evil.com')).toBe('/'); }); it('rejects control/whitespace chars browsers strip mid-parse', () => { // These start with a single "/" so the prefix checks alone would pass // them, but a browser strips the tab/newline/CR and the value // collapses to a protocol-relative //evil.com — the %09/%0A/%0D // open-redirect bypass. expect(safeNext('/\t/evil.com')).toBe('/'); // %09 tab expect(safeNext('/\n/evil.com')).toBe('/'); // %0A newline expect(safeNext('/\r/evil.com')).toBe('/'); // %0D carriage return expect(safeNext('/ /evil.com')).toBe('/'); // space expect(safeNext(`/${String.fromCharCode(0)}/evil.com`)).toBe('/'); // NUL expect(safeNext(`/${String.fromCharCode(0x2028)}/evil.com`)).toBe('/'); // line sep }); it('rejects absolute and scheme URLs', () => { expect(safeNext('https://evil.com')).toBe('/'); expect(safeNext('http://evil.com')).toBe('/'); expect(safeNext('javascript:alert(1)')).toBe('/'); }); it('rejects bare paths without a leading slash', () => { expect(safeNext('library')).toBe('/'); }); });