Unverified Commit 88cb58ab authored by boojack's avatar boojack Committed by GitHub

refactor(web/routing): guard-based auth flow, migrate tests to Vitest (#5848)

parent 587f5b1b
...@@ -44,6 +44,10 @@ jobs: ...@@ -44,6 +44,10 @@ jobs:
working-directory: web working-directory: web
run: pnpm lint run: pnpm lint
- name: Run unit tests
working-directory: web
run: pnpm test
build: build:
name: Build name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
......
...@@ -27,3 +27,10 @@ dist ...@@ -27,3 +27,10 @@ dist
# Git worktrees # Git worktrees
.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/
...@@ -10,7 +10,10 @@ ...@@ -10,7 +10,10 @@
"release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir", "release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir",
"lint": "tsc --noEmit --skipLibCheck && biome check src", "lint": "tsc --noEmit --skipLibCheck && biome check src",
"lint:fix": "biome check --write 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": { "dependencies": {
"@connectrpc/connect": "^2.1.1", "@connectrpc/connect": "^2.1.1",
...@@ -74,6 +77,8 @@ ...@@ -74,6 +77,8 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.7", "@biomejs/biome": "^2.4.7",
"@bufbuild/protobuf": "^2.11.0", "@bufbuild/protobuf": "^2.11.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/katex": "^0.16.8", "@types/katex": "^0.16.8",
...@@ -89,11 +94,13 @@ ...@@ -89,11 +94,13 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"baseline-browser-mapping": "^2.10.8", "baseline-browser-mapping": "^2.10.8",
"jsdom": "^29.0.2",
"long": "^5.3.2", "long": "^5.3.2",
"terser": "^5.46.1", "terser": "^5.46.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^7.2.4" "vite": "^7.2.4",
"vitest": "^4.1.4"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
......
This diff is collapsed.
...@@ -8,6 +8,7 @@ import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; ...@@ -8,6 +8,7 @@ import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { userKeys } from "@/hooks/useUserQueries"; import { userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error"; import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -74,7 +75,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use ...@@ -74,7 +75,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use
} }
if (isInMemoDetailPage) { if (isInMemoDetailPage) {
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived"); navigateTo(memo.state === State.ARCHIVED ? ROUTES.HOME : ROUTES.ARCHIVED);
} }
memoUpdatedCallback(); memoUpdatedCallback();
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]); }, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]);
...@@ -109,7 +110,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use ...@@ -109,7 +110,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use
queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) }); queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) });
} }
if (isInMemoDetailPage) { if (isInMemoDetailPage) {
navigateTo("/"); navigateTo(ROUTES.HOME);
} }
memoUpdatedCallback(); memoUpdatedCallback();
}, [memo.name, memo.parent, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]); }, [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 ...@@ -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 the tag is clicked in a memo detail page, we should navigate to the memo list page.
if (location.pathname.startsWith("/m")) { if (location.pathname.startsWith("/m")) {
const pathname = parentPage || Routes.ROOT; const pathname = parentPage || Routes.ENTRY;
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }])); searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
......
...@@ -30,7 +30,7 @@ const Navigation = (props: Props) => { ...@@ -30,7 +30,7 @@ const Navigation = (props: Props) => {
const homeNavLink: NavLinkItem = { const homeNavLink: NavLinkItem = {
id: "header-memos", id: "header-memos",
path: Routes.ROOT, path: Routes.HOME,
title: t("common.memos"), title: t("common.memos"),
icon: <LibraryIcon className="w-6 h-auto shrink-0" />, icon: <LibraryIcon className="w-6 h-auto shrink-0" />,
}; };
...@@ -77,7 +77,7 @@ const Navigation = (props: Props) => { ...@@ -77,7 +77,7 @@ const Navigation = (props: Props) => {
return ( return (
<header className={cn("w-full h-full overflow-auto flex flex-col justify-between items-start gap-4", className)}> <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"> <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} /> <MemosLogo collapsed={collapsed} />
</NavLink> </NavLink>
<TooltipProvider> <TooltipProvider>
......
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { ArrowUpIcon } from "lucide-react"; import { ArrowUpIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { matchPath } from "react-router-dom";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext"; import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect"; import { userServiceClient } from "@/connect";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { useInfiniteMemos } from "@/hooks/useMemoQueries"; import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { userKeys } from "@/hooks/useUserQueries"; import { userKeys } from "@/hooks/useUserQueries";
import { Routes } from "@/router";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -26,6 +24,8 @@ interface Props { ...@@ -26,6 +24,8 @@ interface Props {
pageSize?: number; pageSize?: number;
showCreator?: boolean; showCreator?: boolean;
enabled?: boolean; enabled?: boolean;
/** When true, render the inline MemoEditor above the list (e.g. on the Home page). */
showMemoEditor?: boolean;
} }
function useAutoFetchWhenNotScrollable({ function useAutoFetchWhenNotScrollable({
...@@ -83,8 +83,7 @@ const PagedMemoList = (props: Props) => { ...@@ -83,8 +83,7 @@ const PagedMemoList = (props: Props) => {
const t = useTranslate(); const t = useTranslate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Show memo editor only on the root route const showMemoEditor = props.showMemoEditor ?? false;
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteMemos( const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteMemos(
{ {
......
...@@ -64,7 +64,7 @@ function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) { ...@@ -64,7 +64,7 @@ function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined); setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
} }
await initialize(); await initialize();
navigateTo(redirectPath || ROUTES.ROOT, { replace: true }); navigateTo(redirectPath || ROUTES.HOME, { replace: true });
} catch (error: unknown) { } catch (error: unknown) {
handleError(error, toast.error, { handleError(error, toast.error, {
fallbackMessage: "Failed to sign in.", fallbackMessage: "Failed to sign in.",
......
...@@ -24,7 +24,7 @@ const MainLayout = () => { ...@@ -24,7 +24,7 @@ const MainLayout = () => {
// Determine context based on current route // Determine context based on current route
const context: MemoExplorerContext = useMemo(() => { 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 (location.pathname === Routes.EXPLORE) return "explore";
if (matchPath(ARCHIVED_ROUTE, location.pathname)) return "archived"; if (matchPath(ARCHIVED_ROUTE, location.pathname)) return "archived";
if (matchPath(PROFILE_ROUTE, location.pathname)) return "profile"; if (matchPath(PROFILE_ROUTE, location.pathname)) return "profile";
......
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { Outlet, useLocation, useSearchParams } from "react-router-dom"; import { Outlet, useLocation, useSearchParams } from "react-router-dom";
import usePrevious from "react-use/lib/usePrevious"; import usePrevious from "react-use/lib/usePrevious";
import Navigation from "@/components/Navigation"; import Navigation from "@/components/Navigation";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import useMediaQuery from "@/hooks/useMediaQuery"; import useMediaQuery from "@/hooks/useMediaQuery";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ROUTES } from "@/router/routes";
import { redirectOnAuthFailure } from "@/utils/auth-redirect";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
const MEMOS_DEPLOY_URL = "https://usememos.com/docs/deploy"; const MEMOS_DEPLOY_URL = "https://usememos.com/docs/deploy";
...@@ -34,25 +30,13 @@ const RootLayout = () => { ...@@ -34,25 +30,13 @@ const RootLayout = () => {
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const sm = useMediaQuery("sm"); const sm = useMediaQuery("sm");
const currentUser = useCurrentUser();
const navigateTo = useNavigateTo();
const { profile } = useInstance(); const { profile } = useInstance();
const { removeFilter } = useMemoFilterContext(); const { removeFilter } = useMemoFilterContext();
const pathname = useMemo(() => location.pathname, [location.pathname]); const { pathname } = location;
const prevPathname = usePrevious(pathname); const prevPathname = usePrevious(pathname);
useEffect(() => { useEffect(() => {
if (!currentUser) { // When the route changes and there is no filter in the search params, remove all filters.
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
if (prevPathname !== pathname && !searchParams.has("filter")) { if (prevPathname !== pathname && !searchParams.has("filter")) {
removeFilter(() => true); removeFilter(() => true);
} }
......
...@@ -7,6 +7,8 @@ import { useAuth } from "@/contexts/AuthContext"; ...@@ -7,6 +7,8 @@ import { useAuth } from "@/contexts/AuthContext";
import { absolutifyLink } from "@/helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error"; import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes";
import { getSafeRedirectPath } from "@/utils/auth-redirect";
import { validateOAuthState } from "@/utils/oauth"; import { validateOAuthState } from "@/utils/oauth";
interface State { interface State {
...@@ -97,8 +99,10 @@ const AuthCallback = () => { ...@@ -97,8 +99,10 @@ const AuthCallback = () => {
errorMessage: "", errorMessage: "",
}); });
await initialize(); await initialize();
// Redirect to return URL if specified, otherwise home // Defense-in-depth: even though `returnUrl` was sanitized before being
navigateTo(returnUrl || "/"); // 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) { } catch (error: unknown) {
handleError(error, () => {}, { handleError(error, () => {}, {
fallbackMessage: "Failed to authenticate.", fallbackMessage: "Failed to authenticate.",
......
...@@ -29,6 +29,7 @@ const Home = () => { ...@@ -29,6 +29,7 @@ const Home = () => {
orderBy={orderBy} orderBy={orderBy}
filter={memoFilter} filter={memoFilter}
enabled={isInitialized} enabled={isInitialized}
showMemoEditor
/> />
</div> </div>
); );
......
...@@ -8,7 +8,6 @@ import { Separator } from "@/components/ui/separator"; ...@@ -8,7 +8,6 @@ import { Separator } from "@/components/ui/separator";
import { identityProviderServiceClient } from "@/connect"; import { identityProviderServiceClient } from "@/connect";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import { absolutifyLink } from "@/helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser";
import { handleError } from "@/lib/error"; import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb"; import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb";
...@@ -18,20 +17,12 @@ import { storeOAuthState } from "@/utils/oauth"; ...@@ -18,20 +17,12 @@ import { storeOAuthState } from "@/utils/oauth";
const SignIn = () => { const SignIn = () => {
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser();
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]); const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
const { generalSetting: instanceGeneralSetting } = useInstance(); const { generalSetting: instanceGeneralSetting } = useInstance();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
const signUpPath = searchParams.toString() ? `${ROUTES.AUTH}/signup?${searchParams.toString()}` : `${ROUTES.AUTH}/signup`; 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. // Prepare identity provider list.
useEffect(() => { useEffect(() => {
const fetchIdentityProviderList = async () => { const fetchIdentityProviderList = async () => {
......
...@@ -77,7 +77,7 @@ const SignUp = () => { ...@@ -77,7 +77,7 @@ const SignUp = () => {
await initAuth(); await initAuth();
// Refetch instance profile to update the initialized status // Refetch instance profile to update the initialized status
await initInstance(); await initInstance();
navigateTo(redirectTarget || ROUTES.ROOT, { replace: true }); navigateTo(redirectTarget || ROUTES.HOME, { replace: true });
} catch (error: unknown) { } catch (error: unknown) {
handleError(error, toast.error, { handleError(error, toast.error, {
fallbackMessage: "Sign up failed", fallbackMessage: "Sign up failed",
......
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 />;
};
import { lazy } from "react"; import { lazy } from "react";
import { createBrowserRouter } from "react-router-dom"; import { createBrowserRouter, type RouteObject } from "react-router-dom";
import App from "@/App"; import App from "@/App";
import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary"; import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import RootLayout from "@/layouts/RootLayout"; import RootLayout from "@/layouts/RootLayout";
import Home from "@/pages/Home"; 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). // Wrap lazy imports to auto-reload on chunk load failure (e.g., after redeployment).
function lazyWithReload<T extends React.ComponentType>(factory: () => Promise<{ default: T }>) { function lazyWithReload<T extends React.ComponentType>(factory: () => Promise<{ default: T }>) {
...@@ -37,13 +39,16 @@ const SignIn = lazyWithReload(() => import("@/pages/SignIn")); ...@@ -37,13 +39,16 @@ const SignIn = lazyWithReload(() => import("@/pages/SignIn"));
const SignUp = lazyWithReload(() => import("@/pages/SignUp")); const SignUp = lazyWithReload(() => import("@/pages/SignUp"));
const UserProfile = lazyWithReload(() => import("@/pages/UserProfile")); const UserProfile = lazyWithReload(() => import("@/pages/UserProfile"));
import { ROUTES } from "./routes"; // Backward compatibility alias.
// Backward compatibility alias
export const Routes = ROUTES; export const Routes = ROUTES;
export { 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: "/", path: "/",
element: <App />, element: <App />,
...@@ -51,31 +56,50 @@ const router = createBrowserRouter([ ...@@ -51,31 +56,50 @@ const router = createBrowserRouter([
children: [ children: [
{ {
path: Routes.AUTH, path: Routes.AUTH,
children: [
// 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: [ children: [
{ path: "", element: <SignIn /> }, { path: "", element: <SignIn /> },
{ path: "admin", element: <AdminSignIn /> }, { path: "admin", element: <AdminSignIn /> },
{ path: "signup", element: <SignUp /> }, { path: "signup", element: <SignUp /> },
{ path: "callback", element: <AuthCallback /> },
], ],
}, },
],
},
{ index: true, element: <LandingRoute /> },
{ {
path: Routes.ROOT, path: Routes.ENTRY,
element: <RootLayout />, element: <RootLayout />,
children: [ children: [
{ {
element: <MainLayout />, element: <MainLayout />,
children: [ children: [
{ path: "", element: <Home /> },
{ path: Routes.EXPLORE, element: <Explore /> }, { path: Routes.EXPLORE, element: <Explore /> },
{ path: Routes.ARCHIVED, element: <Archived /> },
{ path: "u/:username", element: <UserProfile /> }, { path: "u/:username", element: <UserProfile /> },
{
element: <RequireAuthRoute />,
children: [
{ path: Routes.HOME, element: <Home /> },
{ path: Routes.ARCHIVED, element: <Archived /> },
],
},
], ],
}, },
{ path: "memos/:uid", element: <MemoDetail /> },
{ path: "memos/shares/:token", element: <MemoDetail /> },
{
element: <RequireAuthRoute />,
children: [
{ path: Routes.ATTACHMENTS, element: <Attachments /> }, { path: Routes.ATTACHMENTS, element: <Attachments /> },
{ path: Routes.INBOX, element: <Inboxes /> }, { path: Routes.INBOX, element: <Inboxes /> },
{ path: Routes.SETTING, element: <Setting /> }, { path: Routes.SETTING, element: <Setting /> },
{ path: "memos/:uid", element: <MemoDetail /> }, ],
{ path: "memos/shares/:token", element: <MemoDetail /> }, },
{ path: "403", element: <PermissionDenied /> }, { path: "403", element: <PermissionDenied /> },
{ path: "404", element: <NotFound /> }, { path: "404", element: <NotFound /> },
{ path: "*", element: <NotFound /> }, { path: "*", element: <NotFound /> },
...@@ -83,6 +107,8 @@ const router = createBrowserRouter([ ...@@ -83,6 +107,8 @@ const router = createBrowserRouter([
}, },
], ],
}, },
]); ];
const router = createBrowserRouter(routeConfig);
export default router; export default router;
export const ROUTES = { 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", ATTACHMENTS: "/attachments",
INBOX: "/inbox", INBOX: "/inbox",
ARCHIVED: "/archived", ARCHIVED: "/archived",
......
import { clearAccessToken } from "@/auth-state"; import { clearAccessToken } from "@/auth-state";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { buildAuthRoute, isPublicRoute } from "./redirect-safety";
const PUBLIC_ROUTES = [
ROUTES.AUTH, // Authentication pages // Re-export the pure helpers so existing call sites (`@/utils/auth-redirect`)
ROUTES.EXPLORE, // Explore page // keep working without every caller switching to the new module. The side-effectful
ROUTES.SHARED_MEMO + "/", // Shared memo pages (share-link viewer) // `redirectOnAuthFailure` lives here; pure logic lives in `./redirect-safety`.
"/u/", // User profile pages (dynamic) export {
"/memos/", // Individual memo detail pages (dynamic) AUTH_REASON_PARAM,
] as const; AUTH_REASON_PROTECTED_MEMO,
AUTH_REDIRECT_PARAM,
export const AUTH_REDIRECT_PARAM = "redirect"; buildAuthRoute,
export const AUTH_REASON_PARAM = "reason"; getSafeRedirectPath,
export const AUTH_REASON_PROTECTED_MEMO = "protected-memo"; isPublicRoute,
} from "./redirect-safety";
function isPublicRoute(path: string): boolean {
return PUBLIC_ROUTES.some((route) => path.startsWith(route)); /**
} * Imperatively redirects the current document to the auth entry page, preserving
* the current URL as the `redirect` target. Intended for hard-fail auth paths
export function getSafeRedirectPath(path: string | null | undefined): string | undefined { * (e.g. a refresh-token request returning 401 from a non-React context).
if (!path) { *
return undefined; * 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.
*/
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;
}
export function redirectOnAuthFailure( export function redirectOnAuthFailure(
forceRedirect = false, forceRedirect = false,
options?: { options?: {
......
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));
}
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");
});
});
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");
});
});
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/);
});
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/);
});
});
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);
});
});
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();
});
});
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;
}
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,
},
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment