mirror of https://github.com/usememos/memos
refactor(web/routing): guard-based auth flow, migrate tests to Vitest (#5848)
parent
587f5b1b6c
commit
88cb58ab64
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,63 @@
|
||||
import { Navigate, Outlet, useLocation, useSearchParams } from "react-router-dom";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { AUTH_REDIRECT_PARAM, buildAuthRoute, getSafeRedirectPath } from "@/utils/auth-redirect";
|
||||
import { ROUTES } from "./routes";
|
||||
|
||||
/**
|
||||
* Entry-route component mounted at `/`. Performs authentication-aware redirection
|
||||
* to the correct landing page before any business UI renders, preserving the
|
||||
* original query string and hash so bookmarks like `/?filter=foo` keep working.
|
||||
*/
|
||||
export const LandingRoute = () => {
|
||||
const currentUser = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const target = currentUser ? ROUTES.HOME : ROUTES.EXPLORE;
|
||||
|
||||
return (
|
||||
<Navigate
|
||||
to={{
|
||||
pathname: target,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
}}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Guard for routes that require an authenticated user. Unauthenticated visitors
|
||||
* are redirected to `/auth` with the original location preserved as the `redirect`
|
||||
* query parameter, so they return to the intended page after signing in.
|
||||
*/
|
||||
export const RequireAuthRoute = () => {
|
||||
const currentUser = useCurrentUser();
|
||||
const location = useLocation();
|
||||
|
||||
if (!currentUser) {
|
||||
const redirect = `${location.pathname}${location.search}${location.hash}`;
|
||||
return <Navigate to={buildAuthRoute({ redirect })} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Guard for guest-only routes (sign-in and sign-up). Already-authenticated users
|
||||
* are redirected to the requested `redirect` target (when safe) or to `/home`.
|
||||
*
|
||||
* The OAuth callback route (`/auth/callback`) intentionally opts out of this guard:
|
||||
* an authenticated session in another tab must not prevent the callback from
|
||||
* consuming its one-time OAuth state and completing the in-flight sign-in.
|
||||
*/
|
||||
export const RequireGuestRoute = () => {
|
||||
const currentUser = useCurrentUser();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
if (currentUser) {
|
||||
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
|
||||
return <Navigate to={redirectTarget || ROUTES.HOME} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
@ -0,0 +1,72 @@
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
/** Query parameter used to preserve the intended destination across the auth flow. */
|
||||
export const AUTH_REDIRECT_PARAM = "redirect";
|
||||
|
||||
/** Query parameter used to surface why the user was sent to the auth page. */
|
||||
export const AUTH_REASON_PARAM = "reason";
|
||||
|
||||
/** Reason code signalling that the user hit a memo that requires authentication. */
|
||||
export const AUTH_REASON_PROTECTED_MEMO = "protected-memo";
|
||||
|
||||
/**
|
||||
* Validates a post-authentication redirect target.
|
||||
*
|
||||
* Returns the path when it is a safe same-origin internal destination, otherwise `undefined`.
|
||||
* Rejected targets include: non-string / empty, protocol-relative URLs (`//host`), absolute URLs,
|
||||
* and any auth-family route (`/auth`, `/auth/callback`, …) which must not be a landing target
|
||||
* after sign-in.
|
||||
*/
|
||||
export function getSafeRedirectPath(path: string | null | undefined): string | undefined {
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!path.startsWith("/") || path.startsWith("//")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Never let a redirect target point back into the auth flow — it would either
|
||||
// bounce the user in a guest/auth guard loop or hijack the OAuth callback.
|
||||
if (path === ROUTES.AUTH || path.startsWith(`${ROUTES.AUTH}/`) || path.startsWith(`${ROUTES.AUTH}?`)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a URL pointing at the auth entry page, optionally embedding a validated
|
||||
* `redirect` target and a machine-readable `reason` code.
|
||||
*/
|
||||
export function buildAuthRoute(options?: { redirect?: string | null; reason?: string | null }): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
const redirectPath = getSafeRedirectPath(options?.redirect);
|
||||
|
||||
if (redirectPath) {
|
||||
searchParams.set(AUTH_REDIRECT_PARAM, redirectPath);
|
||||
}
|
||||
|
||||
if (options?.reason) {
|
||||
searchParams.set(AUTH_REASON_PARAM, options.reason);
|
||||
}
|
||||
|
||||
const search = searchParams.toString();
|
||||
return search ? `${ROUTES.AUTH}?${search}` : ROUTES.AUTH;
|
||||
}
|
||||
|
||||
const PUBLIC_ROUTE_PREFIXES = [
|
||||
ROUTES.AUTH, // Authentication pages
|
||||
ROUTES.EXPLORE, // Explore page
|
||||
`${ROUTES.SHARED_MEMO}/`, // Shared memo pages (share-link viewer)
|
||||
"/u/", // User profile pages (dynamic)
|
||||
"/memos/", // Individual memo detail pages (dynamic)
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Reports whether a given pathname corresponds to a page that unauthenticated
|
||||
* visitors are allowed to view without being bounced to the auth page.
|
||||
*/
|
||||
export function isPublicRoute(path: string): boolean {
|
||||
return PUBLIC_ROUTE_PREFIXES.some((route) => path.startsWith(route));
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/auth-state", () => ({
|
||||
clearAccessToken: vi.fn(),
|
||||
}));
|
||||
|
||||
import { clearAccessToken } from "@/auth-state";
|
||||
import { redirectOnAuthFailure } from "@/utils/auth-redirect";
|
||||
|
||||
const mockedClearAccessToken = vi.mocked(clearAccessToken);
|
||||
|
||||
type NavigationStub = { replace: ReturnType<typeof vi.fn>; href: string };
|
||||
|
||||
function installLocation(href: string): NavigationStub {
|
||||
const url = new URL(href);
|
||||
const replace = vi.fn((next: string) => {
|
||||
// Mirror real navigation: update the mutable href on subsequent inspection.
|
||||
location.href = new URL(next, url).toString();
|
||||
});
|
||||
const location: NavigationStub = { replace, href: url.toString() };
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: {
|
||||
get href() {
|
||||
return location.href;
|
||||
},
|
||||
set href(value: string) {
|
||||
location.href = value;
|
||||
},
|
||||
pathname: url.pathname,
|
||||
search: url.search,
|
||||
hash: url.hash,
|
||||
origin: url.origin,
|
||||
replace,
|
||||
},
|
||||
});
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
describe("redirectOnAuthFailure", () => {
|
||||
let originalLocation: Location;
|
||||
|
||||
beforeEach(() => {
|
||||
originalLocation = window.location;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing when the user is already on an /auth page", () => {
|
||||
const nav = installLocation("http://localhost/auth?foo=bar");
|
||||
|
||||
redirectOnAuthFailure();
|
||||
|
||||
expect(nav.replace).not.toHaveBeenCalled();
|
||||
expect(mockedClearAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing on a public route by default", () => {
|
||||
const nav = installLocation("http://localhost/explore");
|
||||
|
||||
redirectOnAuthFailure();
|
||||
|
||||
expect(nav.replace).not.toHaveBeenCalled();
|
||||
expect(mockedClearAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the token and redirects to /auth on a protected route", () => {
|
||||
const nav = installLocation("http://localhost/home?tab=pins#latest");
|
||||
|
||||
redirectOnAuthFailure();
|
||||
|
||||
expect(mockedClearAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fhome%3Ftab%3Dpins%23latest");
|
||||
});
|
||||
|
||||
it("honours forceRedirect even on a public route", () => {
|
||||
const nav = installLocation("http://localhost/explore");
|
||||
|
||||
redirectOnAuthFailure(true);
|
||||
|
||||
expect(mockedClearAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fexplore");
|
||||
});
|
||||
|
||||
it("embeds the reason parameter when provided", () => {
|
||||
const nav = installLocation("http://localhost/home");
|
||||
|
||||
redirectOnAuthFailure(false, { reason: "protected-memo" });
|
||||
|
||||
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fhome&reason=protected-memo");
|
||||
});
|
||||
|
||||
it("prefers an explicitly provided redirect target over the current location", () => {
|
||||
const nav = installLocation("http://localhost/home");
|
||||
|
||||
redirectOnAuthFailure(false, { redirect: "/setting" });
|
||||
|
||||
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fsetting");
|
||||
});
|
||||
|
||||
it("drops an unsafe redirect target silently", () => {
|
||||
const nav = installLocation("http://localhost/home");
|
||||
|
||||
redirectOnAuthFailure(false, { redirect: "//evil.example/phish" });
|
||||
|
||||
expect(nav.replace).toHaveBeenCalledWith("/auth");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,202 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/hooks/useCurrentUser", () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
|
||||
|
||||
const mockedUseCurrentUser = vi.mocked(useCurrentUser);
|
||||
|
||||
// Minimal User-like stand-in — guards only check truthiness on the value.
|
||||
const fakeUser = { name: "users/steven" } as unknown as ReturnType<typeof useCurrentUser>;
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location">{`${location.pathname}${location.search}${location.hash}`}</div>;
|
||||
};
|
||||
|
||||
const renderAt = (initialEntry: string, children: ReactNode) =>
|
||||
render(<MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>);
|
||||
|
||||
describe("LandingRoute", () => {
|
||||
it("sends an authenticated visitor from the entry to /home", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/",
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRoute />} />
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
<Route path="/explore" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/home");
|
||||
});
|
||||
|
||||
it("sends an unauthenticated visitor from the entry to /explore", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(undefined);
|
||||
|
||||
renderAt(
|
||||
"/",
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRoute />} />
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
<Route path="/explore" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/explore");
|
||||
});
|
||||
|
||||
it("preserves the query string and hash when redirecting an authenticated visitor", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/?filter=tag:work&sort=desc#top",
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRoute />} />
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/home?filter=tag:work&sort=desc#top");
|
||||
});
|
||||
|
||||
it("preserves the query string and hash when redirecting an unauthenticated visitor", () => {
|
||||
// Covers the regression in issue #5846: bookmarks pointing at `/?filter=...`
|
||||
// must not drop their params on the trip through the landing redirect.
|
||||
mockedUseCurrentUser.mockReturnValue(undefined);
|
||||
|
||||
renderAt(
|
||||
"/?filter=tag:work#latest",
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRoute />} />
|
||||
<Route path="/explore" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/explore?filter=tag:work#latest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RequireAuthRoute", () => {
|
||||
it("renders the protected content for authenticated users", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/home",
|
||||
<Routes>
|
||||
<Route element={<RequireAuthRoute />}>
|
||||
<Route path="/home" element={<div data-testid="protected">secret</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("protected")).toHaveTextContent("secret");
|
||||
});
|
||||
|
||||
it("redirects unauthenticated users to /auth with the preserved location", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(undefined);
|
||||
|
||||
renderAt(
|
||||
"/home?tab=pins#latest",
|
||||
<Routes>
|
||||
<Route element={<RequireAuthRoute />}>
|
||||
<Route path="/home" element={<div data-testid="protected">secret</div>} />
|
||||
</Route>
|
||||
<Route path="/auth" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/auth?redirect=%2Fhome%3Ftab%3Dpins%23latest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RequireGuestRoute", () => {
|
||||
it("renders the auth page when no user is present", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(undefined);
|
||||
|
||||
renderAt(
|
||||
"/auth",
|
||||
<Routes>
|
||||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div data-testid="sign-in">sign in</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("sign-in")).toHaveTextContent("sign in");
|
||||
});
|
||||
|
||||
it("redirects already-authenticated users to /home by default", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/auth",
|
||||
<Routes>
|
||||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/home");
|
||||
});
|
||||
|
||||
it("honours a safe redirect target from the query string", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/auth?redirect=%2Fsetting",
|
||||
<Routes>
|
||||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/setting" element={<LocationProbe />} />
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/setting");
|
||||
});
|
||||
|
||||
it("ignores an auth-family redirect target and falls back to /home", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/auth?redirect=%2Fauth%2Fcallback",
|
||||
<Routes>
|
||||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/home");
|
||||
});
|
||||
|
||||
it("ignores an external redirect target and falls back to /home", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/auth?redirect=%2F%2Fevil.example%2Fphish",
|
||||
<Routes>
|
||||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/home");
|
||||
});
|
||||
});
|
||||
@ -1,67 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkMath from "remark-math";
|
||||
import { SANITIZE_SCHEMA, isTrustedIframeSrc } from "../src/components/MemoContent/constants.ts";
|
||||
|
||||
const TrustedIframe = (props) => {
|
||||
if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return React.createElement("iframe", props);
|
||||
};
|
||||
|
||||
const renderMemoContent = (content) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(ReactMarkdown, {
|
||||
children: content,
|
||||
remarkPlugins: [remarkMath],
|
||||
rehypePlugins: [rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], [rehypeKatex, { throwOnError: false, strict: false }]],
|
||||
components: {
|
||||
iframe: TrustedIframe,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
test("strips user-controlled inline styles from raw HTML spans", () => {
|
||||
const html = renderMemoContent('<span style="position:fixed;inset:0;z-index:99999">overlay</span>');
|
||||
|
||||
assert.match(html, /<span>overlay<\/span>/);
|
||||
assert.doesNotMatch(html, /style=/);
|
||||
assert.doesNotMatch(html, /position:fixed/);
|
||||
});
|
||||
|
||||
test("still renders KaTeX output after sanitizing math marker classes", () => {
|
||||
const html = renderMemoContent("$L$");
|
||||
|
||||
assert.match(html, /class="katex"/);
|
||||
assert.match(html, /class="katex-html"/);
|
||||
});
|
||||
|
||||
test("allows trusted iframe providers only", () => {
|
||||
assert.equal(isTrustedIframeSrc("https://www.youtube.com/embed/abc123"), true);
|
||||
assert.equal(isTrustedIframeSrc("https://www.youtube-nocookie.com/embed/abc123?si=test"), true);
|
||||
assert.equal(isTrustedIframeSrc("https://player.vimeo.com/video/123456"), true);
|
||||
assert.equal(isTrustedIframeSrc("https://open.spotify.com/embed/track/123456"), true);
|
||||
assert.equal(isTrustedIframeSrc("https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/123456"), true);
|
||||
assert.equal(isTrustedIframeSrc("https://www.loom.com/embed/123456"), true);
|
||||
assert.equal(isTrustedIframeSrc("https://www.google.com/maps/embed?pb=test"), true);
|
||||
assert.equal(isTrustedIframeSrc("https://app.diagrams.net/?embed=1"), true);
|
||||
assert.equal(isTrustedIframeSrc("https://www.draw.io/?embed=1"), true);
|
||||
assert.equal(isTrustedIframeSrc("https://evil.example/embed/abc123"), false);
|
||||
});
|
||||
|
||||
test("drops untrusted iframe embeds during rendering", () => {
|
||||
const trusted = renderMemoContent('<iframe src="https://www.youtube.com/embed/abc123" title="demo"></iframe>');
|
||||
const untrusted = renderMemoContent('<iframe src="https://evil.example/embed/abc123" title="demo"></iframe>');
|
||||
|
||||
assert.match(trusted, /<iframe/);
|
||||
assert.match(trusted, /youtube\.com\/embed\/abc123/);
|
||||
assert.doesNotMatch(untrusted, /<iframe/);
|
||||
});
|
||||
@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkMath from "remark-math";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SANITIZE_SCHEMA, isTrustedIframeSrc } from "@/components/MemoContent/constants";
|
||||
|
||||
type IframeProps = React.ComponentProps<"iframe">;
|
||||
|
||||
const TrustedIframe = (props: IframeProps) => {
|
||||
if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) {
|
||||
return null;
|
||||
}
|
||||
return <iframe {...props} />;
|
||||
};
|
||||
|
||||
const renderMemoContent = (content: string): string =>
|
||||
renderToStaticMarkup(
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], [rehypeKatex, { throwOnError: false, strict: false }]]}
|
||||
components={{ iframe: TrustedIframe }}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>,
|
||||
);
|
||||
|
||||
describe("memo content sanitization", () => {
|
||||
it("strips user-controlled inline styles from raw HTML spans", () => {
|
||||
const html = renderMemoContent('<span style="position:fixed;inset:0;z-index:99999">overlay</span>');
|
||||
|
||||
expect(html).toMatch(/<span>overlay<\/span>/);
|
||||
expect(html).not.toMatch(/style=/);
|
||||
expect(html).not.toMatch(/position:fixed/);
|
||||
});
|
||||
|
||||
it("still renders KaTeX output after sanitizing math marker classes", () => {
|
||||
const html = renderMemoContent("$L$");
|
||||
|
||||
expect(html).toMatch(/class="katex"/);
|
||||
expect(html).toMatch(/class="katex-html"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trusted iframe providers", () => {
|
||||
it("accepts trusted providers only", () => {
|
||||
expect(isTrustedIframeSrc("https://www.youtube.com/embed/abc123")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://www.youtube-nocookie.com/embed/abc123?si=test")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://player.vimeo.com/video/123456")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://open.spotify.com/embed/track/123456")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/123456")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://www.loom.com/embed/123456")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://www.google.com/maps/embed?pb=test")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://app.diagrams.net/?embed=1")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://www.draw.io/?embed=1")).toBe(true);
|
||||
expect(isTrustedIframeSrc("https://evil.example/embed/abc123")).toBe(false);
|
||||
});
|
||||
|
||||
it("drops untrusted iframe embeds during rendering", () => {
|
||||
const trusted = renderMemoContent('<iframe src="https://www.youtube.com/embed/abc123" title="demo"></iframe>');
|
||||
const untrusted = renderMemoContent('<iframe src="https://evil.example/embed/abc123" title="demo"></iframe>');
|
||||
|
||||
expect(trusted).toMatch(/<iframe/);
|
||||
expect(trusted).toMatch(/youtube\.com\/embed\/abc123/);
|
||||
expect(untrusted).not.toMatch(/<iframe/);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AUTH_REDIRECT_PARAM, buildAuthRoute, getSafeRedirectPath, isPublicRoute } from "@/utils/redirect-safety";
|
||||
|
||||
describe("getSafeRedirectPath", () => {
|
||||
it("accepts safe same-origin internal paths", () => {
|
||||
expect(getSafeRedirectPath("/home")).toBe("/home");
|
||||
expect(getSafeRedirectPath("/setting")).toBe("/setting");
|
||||
expect(getSafeRedirectPath("/memos/abc")).toBe("/memos/abc");
|
||||
expect(getSafeRedirectPath("/explore?foo=1")).toBe("/explore?foo=1");
|
||||
expect(getSafeRedirectPath("/explore#anchor")).toBe("/explore#anchor");
|
||||
});
|
||||
|
||||
it("rejects empty and non-string input", () => {
|
||||
expect(getSafeRedirectPath(undefined)).toBeUndefined();
|
||||
expect(getSafeRedirectPath(null)).toBeUndefined();
|
||||
expect(getSafeRedirectPath("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects non-internal targets", () => {
|
||||
expect(getSafeRedirectPath("//evil.example")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("https://evil.example")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("http://evil.example/home")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("javascript:alert(1)")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("home")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects auth-family targets", () => {
|
||||
expect(getSafeRedirectPath("/auth")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("/auth/callback")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("/auth/signup")).toBeUndefined();
|
||||
expect(getSafeRedirectPath("/auth?code=abc")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not false-match auth-like paths", () => {
|
||||
expect(getSafeRedirectPath("/authors")).toBe("/authors");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAuthRoute", () => {
|
||||
it("embeds only safe redirect targets", () => {
|
||||
expect(buildAuthRoute({ redirect: "/home" })).toBe("/auth?redirect=%2Fhome");
|
||||
expect(buildAuthRoute({ redirect: "//evil.example" })).toBe("/auth");
|
||||
expect(buildAuthRoute({ redirect: "/auth/callback" })).toBe("/auth");
|
||||
expect(buildAuthRoute({ redirect: null })).toBe("/auth");
|
||||
});
|
||||
|
||||
it("preserves the reason parameter", () => {
|
||||
expect(buildAuthRoute({ reason: "protected-memo" })).toBe("/auth?reason=protected-memo");
|
||||
expect(buildAuthRoute({ redirect: "/memos/abc", reason: "protected-memo" })).toBe(
|
||||
"/auth?redirect=%2Fmemos%2Fabc&reason=protected-memo",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the canonical redirect query key", () => {
|
||||
expect(AUTH_REDIRECT_PARAM).toBe("redirect");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPublicRoute", () => {
|
||||
it("identifies anonymous-accessible page prefixes", () => {
|
||||
expect(isPublicRoute("/auth")).toBe(true);
|
||||
expect(isPublicRoute("/auth/signup")).toBe(true);
|
||||
expect(isPublicRoute("/explore")).toBe(true);
|
||||
expect(isPublicRoute("/memos/abc")).toBe(true);
|
||||
expect(isPublicRoute("/memos/shares/abc")).toBe(true);
|
||||
expect(isPublicRoute("/u/steven")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats authenticated-only pages as non-public", () => {
|
||||
expect(isPublicRoute("/home")).toBe(false);
|
||||
expect(isPublicRoute("/setting")).toBe(false);
|
||||
expect(isPublicRoute("/inbox")).toBe(false);
|
||||
expect(isPublicRoute("/attachments")).toBe(false);
|
||||
expect(isPublicRoute("/archived")).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,75 @@
|
||||
import { isValidElement } from "react";
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routeConfig, ROUTES } from "@/router";
|
||||
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
|
||||
|
||||
// Walk the nested route config and find the first route with the given path,
|
||||
// starting from the provided roots. Returns undefined if nothing matches.
|
||||
function findByPath(routes: RouteObject[], path: string): RouteObject | undefined {
|
||||
for (const route of routes) {
|
||||
if (route.path === path) return route;
|
||||
const hit = route.children ? findByPath(route.children, path) : undefined;
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function elementType(route: RouteObject | undefined): unknown {
|
||||
if (!route?.element || !isValidElement(route.element)) return undefined;
|
||||
return route.element.type;
|
||||
}
|
||||
|
||||
function hasAncestorOfType(routes: RouteObject[], path: string, guardType: unknown): boolean {
|
||||
const walk = (subtree: RouteObject[], ancestorGuards: unknown[]): boolean => {
|
||||
for (const route of subtree) {
|
||||
const nextAncestors = [...ancestorGuards];
|
||||
const type = elementType(route);
|
||||
if (type) nextAncestors.push(type);
|
||||
if (route.path === path) {
|
||||
return nextAncestors.includes(guardType);
|
||||
}
|
||||
if (route.children && walk(route.children, nextAncestors)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return walk(routes, []);
|
||||
}
|
||||
|
||||
describe("router configuration", () => {
|
||||
it("mounts the LandingRoute at the entry index", () => {
|
||||
const root = routeConfig[0];
|
||||
const indexRoute = root.children?.find((r) => r.index);
|
||||
expect(elementType(indexRoute)).toBe(LandingRoute);
|
||||
});
|
||||
|
||||
it("keeps /auth/callback outside the guest-only guard", () => {
|
||||
// Regression guard for issue #5846 follow-up: an authenticated tab elsewhere
|
||||
// must not short-circuit the OAuth callback via RequireGuestRoute.
|
||||
expect(hasAncestorOfType(routeConfig, "callback", RequireGuestRoute)).toBe(false);
|
||||
});
|
||||
|
||||
it("wraps the remaining /auth children in RequireGuestRoute", () => {
|
||||
for (const path of ["", "admin", "signup"]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireGuestRoute)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps authenticated-only pages in RequireAuthRoute", () => {
|
||||
for (const path of [ROUTES.HOME, ROUTES.ARCHIVED, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves public pages outside RequireAuthRoute", () => {
|
||||
for (const path of [ROUTES.EXPLORE, "memos/:uid", "memos/shares/:token", "u/:username"]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes an accessible /auth/callback route definition", () => {
|
||||
expect(findByPath(routeConfig, "callback")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,42 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
// With `globals: false`, @testing-library/react does not auto-register a
|
||||
// cleanup hook, so unmount rendered trees between tests explicitly. This keeps
|
||||
// `screen.getByTestId` from seeing DOM from prior tests in the same file.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Defensive shim: `@/auth-state` constructs a BroadcastChannel at module load
|
||||
// to coordinate token refreshes across tabs. jsdom historically has not shipped
|
||||
// BroadcastChannel, so any future test that transitively imports auth-state
|
||||
// would otherwise throw. Current tests avoid that import path on purpose, but
|
||||
// installing the shim keeps authoring new tests frictionless. No-op when jsdom
|
||||
// already provides an implementation.
|
||||
if (typeof globalThis.BroadcastChannel === "undefined") {
|
||||
class NoopBroadcastChannel {
|
||||
readonly name: string;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
postMessage(_data: unknown): void {}
|
||||
|
||||
close(): void {}
|
||||
|
||||
addEventListener(): void {}
|
||||
|
||||
removeEventListener(): void {}
|
||||
|
||||
dispatchEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error — attach the shim to the global scope for tests.
|
||||
globalThis.BroadcastChannel = NoopBroadcastChannel;
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Vitest configuration. Kept separate from `vite.config.mts` so the dev/build
|
||||
// pipelines stay lean and so tests can opt into jsdom + @testing-library
|
||||
// without dragging them into production bundles.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// Keep in sync with the `@/` alias declared in `vite.config.mts` so that
|
||||
// test-time module resolution matches production/build.
|
||||
alias: {
|
||||
"@/": `${resolve(__dirname, "src")}/`,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./tests/setup.ts"],
|
||||
include: ["tests/**/*.test.{ts,tsx}"],
|
||||
// Keep each test hermetic:
|
||||
// - mockReset clears call history and resets implementations for vi.fn()s,
|
||||
// so module-level mocks (e.g. useCurrentUser) don't leak between tests.
|
||||
// - restoreMocks additionally restores original implementations for spies.
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue