refactor(web/routing): guard-based auth flow, migrate tests to Vitest (#5848)
This commit is contained in:
@@ -44,6 +44,10 @@ jobs:
|
||||
working-directory: web
|
||||
run: pnpm lint
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: web
|
||||
run: pnpm test
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -27,3 +27,10 @@ dist
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Local pnpm store (project-scoped, created when --config.store-dir is set
|
||||
# without an existing store; contains a symlink back to the workspace).
|
||||
.pnpm-store/
|
||||
|
||||
# Frontend test coverage output (Vitest + @vitest/coverage-v8).
|
||||
web/coverage/
|
||||
|
||||
+9
-2
@@ -10,7 +10,10 @@
|
||||
"release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir",
|
||||
"lint": "tsc --noEmit --skipLibCheck && biome check src",
|
||||
"lint:fix": "biome check --write src",
|
||||
"format": "biome format --write src"
|
||||
"format": "biome format --write src",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@connectrpc/connect": "^2.1.1",
|
||||
@@ -74,6 +77,8 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.7",
|
||||
"@bufbuild/protobuf": "^2.11.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/katex": "^0.16.8",
|
||||
@@ -89,11 +94,13 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"baseline-browser-mapping": "^2.10.8",
|
||||
"jsdom": "^29.0.2",
|
||||
"long": "^5.3.2",
|
||||
"terser": "^5.46.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.1.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
Generated
+710
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { userKeys } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
@@ -74,7 +75,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use
|
||||
}
|
||||
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
|
||||
navigateTo(memo.state === State.ARCHIVED ? ROUTES.HOME : ROUTES.ARCHIVED);
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]);
|
||||
@@ -109,7 +110,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) });
|
||||
}
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo("/");
|
||||
navigateTo(ROUTES.HOME);
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, memo.parent, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]);
|
||||
|
||||
@@ -42,7 +42,7 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
|
||||
|
||||
// If the tag is clicked in a memo detail page, we should navigate to the memo list page.
|
||||
if (location.pathname.startsWith("/m")) {
|
||||
const pathname = parentPage || Routes.ROOT;
|
||||
const pathname = parentPage || Routes.ENTRY;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
|
||||
|
||||
@@ -30,7 +30,7 @@ const Navigation = (props: Props) => {
|
||||
|
||||
const homeNavLink: NavLinkItem = {
|
||||
id: "header-memos",
|
||||
path: Routes.ROOT,
|
||||
path: Routes.HOME,
|
||||
title: t("common.memos"),
|
||||
icon: <LibraryIcon className="w-6 h-auto shrink-0" />,
|
||||
};
|
||||
@@ -77,7 +77,7 @@ const Navigation = (props: Props) => {
|
||||
return (
|
||||
<header className={cn("w-full h-full overflow-auto flex flex-col justify-between items-start gap-4", className)}>
|
||||
<div className="w-full px-1 py-1 flex flex-col justify-start items-start space-y-2 overflow-auto overflow-x-hidden shrink">
|
||||
<NavLink className="mb-3 cursor-default" to={currentUser ? Routes.ROOT : Routes.EXPLORE}>
|
||||
<NavLink className="mb-3 cursor-default" to={currentUser ? Routes.HOME : Routes.EXPLORE}>
|
||||
<MemosLogo collapsed={collapsed} />
|
||||
</NavLink>
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowUpIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
||||
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
|
||||
import { userKeys } from "@/hooks/useUserQueries";
|
||||
import { Routes } from "@/router";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
@@ -26,6 +24,8 @@ interface Props {
|
||||
pageSize?: number;
|
||||
showCreator?: boolean;
|
||||
enabled?: boolean;
|
||||
/** When true, render the inline MemoEditor above the list (e.g. on the Home page). */
|
||||
showMemoEditor?: boolean;
|
||||
}
|
||||
|
||||
function useAutoFetchWhenNotScrollable({
|
||||
@@ -83,8 +83,7 @@ const PagedMemoList = (props: Props) => {
|
||||
const t = useTranslate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Show memo editor only on the root route
|
||||
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
|
||||
const showMemoEditor = props.showMemoEditor ?? false;
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteMemos(
|
||||
{
|
||||
|
||||
@@ -64,7 +64,7 @@ function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) {
|
||||
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
|
||||
}
|
||||
await initialize();
|
||||
navigateTo(redirectPath || ROUTES.ROOT, { replace: true });
|
||||
navigateTo(redirectPath || ROUTES.HOME, { replace: true });
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
fallbackMessage: "Failed to sign in.",
|
||||
|
||||
@@ -24,7 +24,7 @@ const MainLayout = () => {
|
||||
|
||||
// Determine context based on current route
|
||||
const context: MemoExplorerContext = useMemo(() => {
|
||||
if (location.pathname === Routes.ROOT) return "home";
|
||||
if (location.pathname === Routes.HOME) return "home";
|
||||
if (location.pathname === Routes.EXPLORE) return "explore";
|
||||
if (matchPath(ARCHIVED_ROUTE, location.pathname)) return "archived";
|
||||
if (matchPath(PROFILE_ROUTE, location.pathname)) return "profile";
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useLocation, useSearchParams } from "react-router-dom";
|
||||
import usePrevious from "react-use/lib/usePrevious";
|
||||
import Navigation from "@/components/Navigation";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { redirectOnAuthFailure } from "@/utils/auth-redirect";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const MEMOS_DEPLOY_URL = "https://usememos.com/docs/deploy";
|
||||
@@ -34,25 +30,13 @@ const RootLayout = () => {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const sm = useMediaQuery("sm");
|
||||
const currentUser = useCurrentUser();
|
||||
const navigateTo = useNavigateTo();
|
||||
const { profile } = useInstance();
|
||||
const { removeFilter } = useMemoFilterContext();
|
||||
const pathname = useMemo(() => location.pathname, [location.pathname]);
|
||||
const { pathname } = location;
|
||||
const prevPathname = usePrevious(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) {
|
||||
if (pathname === ROUTES.ROOT) {
|
||||
navigateTo(ROUTES.EXPLORE);
|
||||
} else {
|
||||
redirectOnAuthFailure();
|
||||
}
|
||||
}
|
||||
}, [currentUser, pathname, navigateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
// When the route changes and there is no filter in the search params, remove all filters
|
||||
// When the route changes and there is no filter in the search params, remove all filters.
|
||||
if (prevPathname !== pathname && !searchParams.has("filter")) {
|
||||
removeFilter(() => true);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { getSafeRedirectPath } from "@/utils/auth-redirect";
|
||||
import { validateOAuthState } from "@/utils/oauth";
|
||||
|
||||
interface State {
|
||||
@@ -97,8 +99,10 @@ const AuthCallback = () => {
|
||||
errorMessage: "",
|
||||
});
|
||||
await initialize();
|
||||
// Redirect to return URL if specified, otherwise home
|
||||
navigateTo(returnUrl || "/");
|
||||
// Defense-in-depth: even though `returnUrl` was sanitized before being
|
||||
// stored (see storeOAuthState in SignIn), re-validate on the way out so
|
||||
// a corrupted state entry can never be used for an open redirect.
|
||||
navigateTo(getSafeRedirectPath(returnUrl) ?? ROUTES.HOME);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, () => {}, {
|
||||
fallbackMessage: "Failed to authenticate.",
|
||||
|
||||
@@ -29,6 +29,7 @@ const Home = () => {
|
||||
orderBy={orderBy}
|
||||
filter={memoFilter}
|
||||
enabled={isInitialized}
|
||||
showMemoEditor
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { identityProviderServiceClient } from "@/connect";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb";
|
||||
@@ -18,20 +17,12 @@ import { storeOAuthState } from "@/utils/oauth";
|
||||
|
||||
const SignIn = () => {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
|
||||
const { generalSetting: instanceGeneralSetting } = useInstance();
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
|
||||
const signUpPath = searchParams.toString() ? `${ROUTES.AUTH}/signup?${searchParams.toString()}` : `${ROUTES.AUTH}/signup`;
|
||||
|
||||
// Redirect to root page if already signed in.
|
||||
useEffect(() => {
|
||||
if (currentUser?.name) {
|
||||
window.location.href = redirectTarget || ROUTES.ROOT;
|
||||
}
|
||||
}, [currentUser, redirectTarget]);
|
||||
|
||||
// Prepare identity provider list.
|
||||
useEffect(() => {
|
||||
const fetchIdentityProviderList = async () => {
|
||||
|
||||
@@ -77,7 +77,7 @@ const SignUp = () => {
|
||||
await initAuth();
|
||||
// Refetch instance profile to update the initialized status
|
||||
await initInstance();
|
||||
navigateTo(redirectTarget || ROUTES.ROOT, { replace: true });
|
||||
navigateTo(redirectTarget || ROUTES.HOME, { replace: true });
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
fallbackMessage: "Sign up failed",
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
+41
-15
@@ -1,10 +1,12 @@
|
||||
import { lazy } from "react";
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import { createBrowserRouter, type RouteObject } from "react-router-dom";
|
||||
import App from "@/App";
|
||||
import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import RootLayout from "@/layouts/RootLayout";
|
||||
import Home from "@/pages/Home";
|
||||
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "./guards";
|
||||
import { ROUTES } from "./routes";
|
||||
|
||||
// Wrap lazy imports to auto-reload on chunk load failure (e.g., after redeployment).
|
||||
function lazyWithReload<T extends React.ComponentType>(factory: () => Promise<{ default: T }>) {
|
||||
@@ -37,13 +39,16 @@ const SignIn = lazyWithReload(() => import("@/pages/SignIn"));
|
||||
const SignUp = lazyWithReload(() => import("@/pages/SignUp"));
|
||||
const UserProfile = lazyWithReload(() => import("@/pages/UserProfile"));
|
||||
|
||||
import { ROUTES } from "./routes";
|
||||
|
||||
// Backward compatibility alias
|
||||
// Backward compatibility alias.
|
||||
export const Routes = ROUTES;
|
||||
export { ROUTES };
|
||||
|
||||
const router = createBrowserRouter([
|
||||
/**
|
||||
* Static route configuration. Exported so tests can assert on the tree shape
|
||||
* (e.g. that `/auth/callback` stays outside the guest-only guard subtree) and
|
||||
* so integration tests can drive a `createMemoryRouter` over the same tree.
|
||||
*/
|
||||
export const routeConfig: RouteObject[] = [
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
@@ -52,30 +57,49 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: Routes.AUTH,
|
||||
children: [
|
||||
{ path: "", element: <SignIn /> },
|
||||
{ path: "admin", element: <AdminSignIn /> },
|
||||
{ path: "signup", element: <SignUp /> },
|
||||
// The OAuth callback must run regardless of the current session — an
|
||||
// authenticated tab elsewhere must not block it from consuming its
|
||||
// one-time OAuth state. Keep it outside the guest-only subtree.
|
||||
{ path: "callback", element: <AuthCallback /> },
|
||||
{
|
||||
element: <RequireGuestRoute />,
|
||||
children: [
|
||||
{ path: "", element: <SignIn /> },
|
||||
{ path: "admin", element: <AdminSignIn /> },
|
||||
{ path: "signup", element: <SignUp /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ index: true, element: <LandingRoute /> },
|
||||
{
|
||||
path: Routes.ROOT,
|
||||
path: Routes.ENTRY,
|
||||
element: <RootLayout />,
|
||||
children: [
|
||||
{
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{ path: "", element: <Home /> },
|
||||
{ path: Routes.EXPLORE, element: <Explore /> },
|
||||
{ path: Routes.ARCHIVED, element: <Archived /> },
|
||||
{ path: "u/:username", element: <UserProfile /> },
|
||||
{
|
||||
element: <RequireAuthRoute />,
|
||||
children: [
|
||||
{ path: Routes.HOME, element: <Home /> },
|
||||
{ path: Routes.ARCHIVED, element: <Archived /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: Routes.ATTACHMENTS, element: <Attachments /> },
|
||||
{ path: Routes.INBOX, element: <Inboxes /> },
|
||||
{ path: Routes.SETTING, element: <Setting /> },
|
||||
{ path: "memos/:uid", element: <MemoDetail /> },
|
||||
{ path: "memos/shares/:token", element: <MemoDetail /> },
|
||||
{
|
||||
element: <RequireAuthRoute />,
|
||||
children: [
|
||||
{ path: Routes.ATTACHMENTS, element: <Attachments /> },
|
||||
{ path: Routes.INBOX, element: <Inboxes /> },
|
||||
{ path: Routes.SETTING, element: <Setting /> },
|
||||
],
|
||||
},
|
||||
{ path: "403", element: <PermissionDenied /> },
|
||||
{ path: "404", element: <NotFound /> },
|
||||
{ path: "*", element: <NotFound /> },
|
||||
@@ -83,6 +107,8 @@ const router = createBrowserRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
const router = createBrowserRouter(routeConfig);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export const ROUTES = {
|
||||
ROOT: "/",
|
||||
// Entry-only route. Hosts the landing redirect, never a business page.
|
||||
ENTRY: "/",
|
||||
// The authenticated user's primary workspace page.
|
||||
HOME: "/home",
|
||||
ATTACHMENTS: "/attachments",
|
||||
INBOX: "/inbox",
|
||||
ARCHIVED: "/archived",
|
||||
|
||||
@@ -1,50 +1,27 @@
|
||||
import { clearAccessToken } from "@/auth-state";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { buildAuthRoute, isPublicRoute } from "./redirect-safety";
|
||||
|
||||
const PUBLIC_ROUTES = [
|
||||
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;
|
||||
|
||||
export const AUTH_REDIRECT_PARAM = "redirect";
|
||||
export const AUTH_REASON_PARAM = "reason";
|
||||
export const AUTH_REASON_PROTECTED_MEMO = "protected-memo";
|
||||
|
||||
function isPublicRoute(path: string): boolean {
|
||||
return PUBLIC_ROUTES.some((route) => path.startsWith(route));
|
||||
}
|
||||
|
||||
export function getSafeRedirectPath(path: string | null | undefined): string | undefined {
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!path.startsWith("/") || path.startsWith("//")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// Re-export the pure helpers so existing call sites (`@/utils/auth-redirect`)
|
||||
// keep working without every caller switching to the new module. The side-effectful
|
||||
// `redirectOnAuthFailure` lives here; pure logic lives in `./redirect-safety`.
|
||||
export {
|
||||
AUTH_REASON_PARAM,
|
||||
AUTH_REASON_PROTECTED_MEMO,
|
||||
AUTH_REDIRECT_PARAM,
|
||||
buildAuthRoute,
|
||||
getSafeRedirectPath,
|
||||
isPublicRoute,
|
||||
} from "./redirect-safety";
|
||||
|
||||
/**
|
||||
* Imperatively redirects the current document to the auth entry page, preserving
|
||||
* the current URL as the `redirect` target. Intended for hard-fail auth paths
|
||||
* (e.g. a refresh-token request returning 401 from a non-React context).
|
||||
*
|
||||
* No-ops when the user is already on an auth page or on a public page that
|
||||
* does not require authentication, unless `forceRedirect` is set.
|
||||
*/
|
||||
export function redirectOnAuthFailure(
|
||||
forceRedirect = false,
|
||||
options?: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user