mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
feat(sharing): serve a configured public resume at root (#3470)
This commit is contained in:
@@ -9,6 +9,10 @@ SERVER_PORT="3001"
|
||||
# OpenGraph metadata, and absolute upload URLs.
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Optional: serve one already-public resume at /. Use the ID from /builder/<id>.
|
||||
# Unset or blank keeps the marketing home. Restart after changes.
|
||||
# ROOT_RESUME_ID=
|
||||
|
||||
# --- Database (PostgreSQL) ---
|
||||
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
|
||||
# when running directly on your machine, `localhost` is typical.
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
env: { APP_URL: "https://rxresu.me" },
|
||||
env: { APP_URL: "https://rxresu.me", ROOT_RESUME_ID: undefined as string | undefined },
|
||||
serveStatic: vi.fn((_options?: unknown) => vi.fn()),
|
||||
getPublicResumeSocialMeta: vi.fn(),
|
||||
}));
|
||||
@@ -45,6 +45,7 @@ const staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | un
|
||||
describe("web app fallback classification", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.env.ROOT_RESUME_ID = undefined;
|
||||
vi.mocked(fs.readFile).mockResolvedValue("<html>app</html>");
|
||||
mocks.getPublicResumeSocialMeta.mockResolvedValue(null);
|
||||
});
|
||||
@@ -289,3 +290,28 @@ describe("web app fallback classification", () => {
|
||||
expect(await unknownResponse.text()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("configured root shell", () => {
|
||||
it.each(["GET", "HEAD"])("serves no-store noindex headers for %s", async (method) => {
|
||||
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
|
||||
const response = await handleWebApp(new Request("https://attacker.example/", { method }));
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
});
|
||||
it("uses configured canonical root without leaking ID or marketing metadata", async () => {
|
||||
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
'<html><head><title>Marketing title</title><meta name="description" content="Marketing copy."></head><body></body></html>',
|
||||
);
|
||||
const html = await (
|
||||
await handleWebApp(
|
||||
new Request("https://attacker.example/?id=other", {
|
||||
headers: { host: "attacker.example", "x-forwarded-host": "evil.example" },
|
||||
}),
|
||||
)
|
||||
).text();
|
||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/" data-root-resume-shell>');
|
||||
expect(html).toContain('<meta name="robots" content="noindex, follow" data-root-resume-shell>');
|
||||
expect(html).not.toMatch(/private-or-missing-id|attacker|evil|Marketing|application\/ld\+json|timelapse/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,6 +258,14 @@ export const serveWebDistStatic = serveStatic({
|
||||
});
|
||||
|
||||
function getFallbackResponseHeaders(pathname: string) {
|
||||
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
|
||||
return {
|
||||
"Content-Type": "text/html; charset=UTF-8",
|
||||
"X-Robots-Tag": "noindex, follow",
|
||||
"Cache-Control": "private, no-store",
|
||||
...BASE_SECURITY_HEADERS,
|
||||
};
|
||||
}
|
||||
if (pathname === "/" || indexableAppPaths.has(pathname)) {
|
||||
return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
|
||||
}
|
||||
@@ -299,6 +307,16 @@ export async function handleWebApp(request: Request) {
|
||||
const html = await fs.readFile(indexHtmlPath, "utf-8");
|
||||
const canonicalUrl = new URL("/", env.APP_URL).toString();
|
||||
|
||||
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
|
||||
// Root configuration never discloses a target in the HTML shell. The public API
|
||||
// gates data and browser metadata; shell requests must not count extra views.
|
||||
const shell = html
|
||||
.replace(/<title>[^<]*<\/title>/, "<title>Reactive Resume</title>")
|
||||
.replace(/<meta\s+name="description"[^>]*>/, '<meta name="description" content="">');
|
||||
const markup = `<link rel="canonical" href="${escapeAttribute(canonicalUrl)}" data-root-resume-shell><meta name="robots" content="noindex, follow" data-root-resume-shell>`;
|
||||
return new Response(shell.replace("</head>", `${markup}</head>`), { headers });
|
||||
}
|
||||
|
||||
if (pathname === "/") {
|
||||
return new Response(html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`), { headers });
|
||||
}
|
||||
|
||||
@@ -18,19 +18,18 @@ const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
type ResumePasswordPageProps = {
|
||||
username: string;
|
||||
slug: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export function ResumePasswordPage({ redirectPath }: Props) {
|
||||
export function ResumePasswordPage({ username, slug, redirectPath }: ResumePasswordPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { mutate: verifyPassword } = useMutation(orpc.resume.verifyPassword.mutationOptions());
|
||||
|
||||
const [username, slug] = redirectPath.split("/").slice(1) as [string, string];
|
||||
if (!username || !slug) throw navigate({ to: "/" });
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resumePasswordSearchSchema } from "./resume-password-search";
|
||||
|
||||
describe("resume password continuation", () => {
|
||||
it("keeps ordinary slug redirects compatible", () => {
|
||||
expect(resumePasswordSearchSchema.parse({ redirect: "/owner/resume" })).toEqual({ redirect: "/owner/resume" });
|
||||
});
|
||||
it("accepts root return independently of the verification identity", () => {
|
||||
expect(resumePasswordSearchSchema.parse({ redirect: "/owner/resume", returnTo: "/" })).toEqual({
|
||||
redirect: "/owner/resume",
|
||||
returnTo: "/",
|
||||
});
|
||||
});
|
||||
it.each(["//evil.example", "https://evil.example", "/\\evil.example", "/other/path", "/?next=evil"])(
|
||||
"rejects return path %s",
|
||||
(returnTo) => {
|
||||
expect(resumePasswordSearchSchema.safeParse({ redirect: "/owner/resume", returnTo }).success).toBe(false);
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
"/",
|
||||
"//evil.example",
|
||||
"/owner/slug/extra",
|
||||
"/owner/slug?next=evil",
|
||||
"/owner/%2f%2fevil",
|
||||
"/owner/\\evil",
|
||||
"/owner/slug#hash",
|
||||
])("rejects malformed verification identity %s", (redirect) => {
|
||||
expect(resumePasswordSearchSchema.safeParse({ redirect, returnTo: "/" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import z from "zod";
|
||||
|
||||
// Keep the existing slug-shaped `redirect` as verification identity. The optional
|
||||
// continuation is deliberately limited to the configured instance root.
|
||||
export const resumePasswordSearchSchema = z.object({
|
||||
redirect: z.string().regex(/^\/[^/\\?#%\s]+\/[^/\\?#%\s]+$/),
|
||||
returnTo: z.literal("/").optional(),
|
||||
});
|
||||
@@ -130,3 +130,26 @@ describe("PublicResumeRoute", () => {
|
||||
expect(viewerFrame).not.toHaveClass("min-h-0", "flex-1", "overflow-hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PublicResumePage at root", () => {
|
||||
it("renders supplied identity and links to dashboard without slug route hooks", async () => {
|
||||
const { PublicResumePage } = await import("./public-resume");
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PublicResumePage
|
||||
resume={publicResumeMock.resume}
|
||||
username="root-owner"
|
||||
slug="renamed"
|
||||
flags={publicResumeMock.flags}
|
||||
isRoot
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Build your own resume/ })).toHaveAttribute("href", "/dashboard");
|
||||
expect(screen.getByRole("main")).toHaveAttribute("id", "main-content");
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(sampleResumeData.basics.name);
|
||||
expect(publicResumeMock.useResumeExport).toHaveBeenCalledWith(publicResumeMock.resume, {
|
||||
publicResumePdf: { publicResume: { username: "root-owner", slug: "renamed" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CircleNotchIcon, DownloadSimpleIcon } from "@phosphor-icons/react";
|
||||
@@ -18,6 +19,18 @@ export function PublicResumeRoute() {
|
||||
const { flags } = publicResumeRoute.useRouteContext();
|
||||
|
||||
const { data: resume } = useQuery(orpc.resume.getBySlug.queryOptions({ input: { username, slug } }));
|
||||
return <PublicResumePage resume={resume} username={username} slug={slug} flags={flags} />;
|
||||
}
|
||||
|
||||
type PublicResumePageProps = {
|
||||
resume: { id?: string; name: string; slug: string; data: ResumeData; showDownloadButtons?: boolean } | undefined;
|
||||
username: string;
|
||||
slug: string;
|
||||
flags: { disableSignups: boolean };
|
||||
isRoot?: boolean;
|
||||
};
|
||||
|
||||
export function PublicResumePage({ resume, username, slug, flags, isRoot = false }: PublicResumePageProps) {
|
||||
const publicResume = useMemo(() => ({ username, slug }), [slug, username]);
|
||||
const { onDownloadPDF, isExporting } = useResumeExport(resume, {
|
||||
...(resume ? { publicResumePdf: { publicResume } } : {}),
|
||||
@@ -51,14 +64,14 @@ export function PublicResumeRoute() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="w-full max-w-5xl bg-white print:max-w-full">
|
||||
<main id="main-content" className="w-full max-w-5xl bg-white print:max-w-full">
|
||||
<PdfViewer data={resume.data} className="block w-full" publicResume={publicResume} />
|
||||
</main>
|
||||
|
||||
{!flags.disableSignups && (
|
||||
<footer className="flex justify-center print:hidden">
|
||||
<a
|
||||
href="/"
|
||||
href={isRoot ? "/dashboard" : "/"}
|
||||
className="flex items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
|
||||
>
|
||||
<BrandIcon variant="icon" className="size-5" />
|
||||
|
||||
@@ -8,6 +8,19 @@ if (!rootElement) throw new Error("Root element not found");
|
||||
|
||||
const router = await getRouter();
|
||||
|
||||
// Server metadata describes the initial URL. The SPA router owns these tags after
|
||||
// startup, including navigation into root mode from another marketing/public page.
|
||||
const serverSeoSelectors = [
|
||||
"[data-root-resume-shell]",
|
||||
'head link[rel="canonical"]',
|
||||
'head script[type="application/ld+json"]',
|
||||
'head meta[property^="og:"]',
|
||||
'head meta[name^="twitter:"]',
|
||||
];
|
||||
document.querySelectorAll(serverSeoSelectors.join(",")).forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createNoindexFollowMeta, createResumeSocialMeta, getCanonicalRootUrl }
|
||||
type LoaderData = Omit<RouterOutput["resume"]["getBySlug"], "data"> & { data: ResumeData };
|
||||
|
||||
export const Route = createFileRoute("/$username/$slug")({
|
||||
ssr: "data-only",
|
||||
component: lazyRouteComponent(() => import("@/features/resume/public/public-resume"), "PublicResumeRoute"),
|
||||
loader: async ({ context, params }) => {
|
||||
const { username, slug } = params;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (options: unknown) => ({ options }),
|
||||
lazyRouteComponent: () => () => null,
|
||||
redirect: (options: unknown) => options,
|
||||
}));
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: { resume: { getRoot: { queryOptions: () => ({ queryKey: ["root"] }) } } },
|
||||
}));
|
||||
// Marketing sections are unrelated to the loader/head boundary under test.
|
||||
vi.mock("./-sections/donate", () => ({ DonationBanner: () => null }));
|
||||
vi.mock("./-sections/faq", () => ({ Faq: () => null }));
|
||||
vi.mock("./-sections/features", () => ({ Features: () => null }));
|
||||
vi.mock("./-sections/footer", () => ({ Footer: () => null }));
|
||||
vi.mock("./-sections/hero", () => ({ Hero: () => null }));
|
||||
vi.mock("./-sections/prefooter", () => ({ Prefooter: () => null }));
|
||||
vi.mock("./-sections/sponsors", () => ({ Sponsors: () => null }));
|
||||
vi.mock("./-sections/statistics", () => ({ Statistics: () => null }));
|
||||
vi.mock("./-sections/templates", () => ({ Templates: () => null }));
|
||||
vi.mock("./-sections/testimonials", () => ({ Testimonials: () => null }));
|
||||
const { Route } = await import("./index");
|
||||
|
||||
describe("home root mode", () => {
|
||||
it("uses server canonical root for public metadata", async () => {
|
||||
const head = await Route.options.head?.({
|
||||
loaderData: {
|
||||
root: {
|
||||
status: "public",
|
||||
canonicalUrl: "https://configured.example/",
|
||||
username: "owner",
|
||||
slug: "resume",
|
||||
resume: { data: defaultResumeData, name: "Root Fixture" },
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
expect(head).toMatchObject({ links: [{ rel: "canonical", href: "https://configured.example/" }] });
|
||||
expect(head?.meta).toContainEqual({ name: "robots", content: "noindex, follow" });
|
||||
expect(head?.scripts).toBeUndefined();
|
||||
});
|
||||
it("keeps unavailable metadata free from target details and marketing structured data", async () => {
|
||||
const head = await Route.options.head?.({
|
||||
loaderData: { root: { status: "unavailable", canonicalUrl: "https://configured.example/" } },
|
||||
} as never);
|
||||
expect(head?.meta).toContainEqual({ name: "robots", content: "noindex, follow" });
|
||||
expect(head?.scripts).toBeUndefined();
|
||||
});
|
||||
it("retains marketing structured data when disabled", async () => {
|
||||
const head = await Route.options.head?.({ loaderData: { root: { status: "disabled" } } } as never);
|
||||
expect(head?.scripts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createRootStructuredDataScript, getCanonicalRootUrl } from "@/libs/seo";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { ClientOnly, createFileRoute, lazyRouteComponent, redirect } from "@tanstack/react-router";
|
||||
import { getResumeSocialMeta } from "@reactive-resume/resume/social-meta";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { NotFoundScreen } from "@/components/layout/not-found-screen";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import {
|
||||
createNoindexFollowMeta,
|
||||
createResumeSocialMeta,
|
||||
createRootStructuredDataScript,
|
||||
getCanonicalRootUrl,
|
||||
} from "@/libs/seo";
|
||||
import { DonationBanner } from "./-sections/donate";
|
||||
import { Faq } from "./-sections/faq";
|
||||
import { Features } from "./-sections/features";
|
||||
@@ -11,9 +21,44 @@ import { Statistics } from "./-sections/statistics";
|
||||
import { Templates } from "./-sections/templates";
|
||||
import { Testimonials } from "./-sections/testimonials";
|
||||
|
||||
const PublicResumePage = lazyRouteComponent(() => import("@/features/resume/public/public-resume"), "PublicResumePage");
|
||||
|
||||
export const Route = createFileRoute("/_home/")({
|
||||
component: RouteComponent,
|
||||
head: () => {
|
||||
loader: async ({ context }) => ({
|
||||
root: await context.queryClient.fetchQuery(orpc.resume.getRoot.queryOptions({ staleTime: 0 })),
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (error instanceof ORPCError && error.code === "NEED_PASSWORD") {
|
||||
const { username, slug } = error.data as { username: string; slug: string };
|
||||
throw redirect({ to: "/auth/resume-password", search: { redirect: `/${username}/${slug}`, returnTo: "/" } });
|
||||
}
|
||||
},
|
||||
head: ({ loaderData }) => {
|
||||
const root = loaderData?.root;
|
||||
if (root && root.status !== "disabled") {
|
||||
const { canonicalUrl } = root;
|
||||
if (root.status === "unavailable") {
|
||||
return {
|
||||
meta: [{ title: "Reactive Resume" }, createNoindexFollowMeta()],
|
||||
links: [{ rel: "canonical", href: canonicalUrl }],
|
||||
};
|
||||
}
|
||||
const social = getResumeSocialMeta(root.resume.data, root.resume.name || "Resume");
|
||||
return {
|
||||
meta: [
|
||||
{ title: `${social.name} - Reactive Resume` },
|
||||
createNoindexFollowMeta(),
|
||||
...createResumeSocialMeta({
|
||||
canonicalUrl,
|
||||
title: social.title,
|
||||
description: social.description,
|
||||
imageUrl: `${canonicalUrl}opengraph/banner.jpg`,
|
||||
}),
|
||||
],
|
||||
links: [{ rel: "canonical", href: canonicalUrl }],
|
||||
};
|
||||
}
|
||||
const appUrl = typeof window !== "undefined" ? window.location.origin : "https://rxresu.me";
|
||||
const canonicalUrl = getCanonicalRootUrl(appUrl);
|
||||
|
||||
@@ -29,6 +74,20 @@ export const Route = createFileRoute("/_home/")({
|
||||
|
||||
function RouteComponent() {
|
||||
const { flags } = Route.useRouteContext();
|
||||
const { root } = Route.useLoaderData();
|
||||
if (root.status === "unavailable")
|
||||
return (
|
||||
<main id="main-content">
|
||||
<NotFoundScreen />
|
||||
</main>
|
||||
);
|
||||
if (root.status === "public") {
|
||||
return (
|
||||
<ClientOnly fallback={<LoadingScreen />}>
|
||||
<PublicResumePage resume={root.resume} username={root.username} slug={root.slug} flags={flags} isRoot />
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main id="main-content" className="relative">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute, Outlet, useMatch } from "@tanstack/react-router";
|
||||
import { Header } from "./-sections/header";
|
||||
|
||||
export const Route = createFileRoute("/_home")({
|
||||
@@ -7,6 +7,9 @@ export const Route = createFileRoute("/_home")({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const rootMatch = useMatch({ from: "/_home/", shouldThrow: false });
|
||||
const rootMode = rootMatch?.loaderData?.root.status;
|
||||
const showMarketingHeader = !rootMode || rootMode === "disabled";
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
@@ -16,7 +19,7 @@ function RouteComponent() {
|
||||
<Trans>Skip to main content</Trans>
|
||||
</a>
|
||||
|
||||
<Header />
|
||||
{showMarketingHeader && <Header />}
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { createFileRoute, redirect, SearchParamError } from "@tanstack/react-router";
|
||||
import z from "zod";
|
||||
import { ResumePasswordPage } from "@/features/auth/pages/resume-password";
|
||||
|
||||
const searchSchema = z.object({
|
||||
redirect: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^\/[^/]+\/[^/]+$/),
|
||||
});
|
||||
import { resumePasswordSearchSchema } from "@/features/auth/resume-password-search";
|
||||
|
||||
export const Route = createFileRoute("/auth/resume-password")({
|
||||
component: RouteComponent,
|
||||
validateSearch: searchSchema,
|
||||
validateSearch: resumePasswordSearchSchema,
|
||||
onError: (error) => {
|
||||
if (error instanceof SearchParamError) {
|
||||
throw redirect({ to: "/" });
|
||||
@@ -20,7 +13,8 @@ export const Route = createFileRoute("/auth/resume-password")({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { redirect } = Route.useSearch();
|
||||
const { redirect, returnTo } = Route.useSearch();
|
||||
const [username, slug] = redirect.slice(1).split("/") as [string, string];
|
||||
|
||||
return <ResumePasswordPage redirectPath={redirect} />;
|
||||
return <ResumePasswordPage username={username} slug={slug} redirectPath={returnTo ?? redirect} />;
|
||||
}
|
||||
|
||||
@@ -574,3 +574,21 @@ A healthy response returns HTTP 200. If you get a different status code, the JSO
|
||||
- **Fix**: Set `S3_FORCE_PATH_STYLE="true"` in your environment. This is required for most self-hosted S3-compatible services like MinIO, SeaweedFS, etc.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Serve a public resume at the instance root
|
||||
|
||||
To display one public resume at `/` instead of the marketing home, set the optional server environment variable `ROOT_RESUME_ID` on the application service:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
APP_URL: https://resume.example.com
|
||||
ROOT_RESUME_ID: your-resume-id
|
||||
```
|
||||
|
||||
Find the resume ID in its owner's builder URL: `/builder/<resume-id>`. The resume must already have **Allow Public Access** enabled in Sharing. This setting does not change its visibility. Password protection and the download-button preference still apply, and the ordinary `/<username>/<slug>` URL continues to work. Renaming the username or slug does not change the configured ID.
|
||||
|
||||
Restart the application after setting or changing `ROOT_RESUME_ID`. With Docker Compose, run `docker compose up -d` to recreate the application with the new environment. Unset the variable or leave it blank, then restart, to restore the marketing home. A missing, deleted, or private target shows an unavailable page, including when its owner visits `/`.
|
||||
|
||||
Keep `APP_URL` set to the public origin and proxy the whole application normally, including API, uploads, fonts, and assets. Root mode uses that configured origin for its canonical URL; it does not infer a domain from request headers. A successful password challenge returns visitors to `/`.
|
||||
|
||||
This is a single-resume setting for one self-hosted instance. It does not register custom domains, manage DNS or TLS, or hide the rest of the application. Login and the dashboard remain available at their usual paths.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { getRootResume } from "./root";
|
||||
|
||||
const fixture = () => ({
|
||||
config: { rootResumeId: "root-id", appUrl: "https://resume.example/base?ignored=yes" },
|
||||
findTarget: vi.fn(async (_id: string) => ({ username: "owner", slug: "current-slug", isPublic: true })),
|
||||
getBySlug: vi.fn(async (_input: unknown) => ({
|
||||
id: "root-id",
|
||||
name: "Resume",
|
||||
slug: "current-slug",
|
||||
data: defaultResumeData,
|
||||
tags: [],
|
||||
isPublic: true,
|
||||
isLocked: false,
|
||||
showDownloadButtons: false,
|
||||
hasPassword: false,
|
||||
})),
|
||||
});
|
||||
const request = { requestHeaders: new Headers({ host: "attacker.example", "x-forwarded-host": "attacker.example" }) };
|
||||
|
||||
describe("configured root public resume", () => {
|
||||
it.each([undefined, "", " "])("disables root mode for %s without a lookup", async (rootResumeId) => {
|
||||
const deps = fixture();
|
||||
const result = await getRootResume(request, { ...deps, config: { ...deps.config, rootResumeId } });
|
||||
expect(result).toEqual({ status: "disabled" });
|
||||
expect(deps.findTarget).not.toHaveBeenCalled();
|
||||
expect(deps.getBySlug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([null, { username: "secret-owner", slug: "secret-slug", isPublic: false }])(
|
||||
"does not disclose an unavailable target to its owner",
|
||||
async (target) => {
|
||||
const deps = fixture();
|
||||
const result = await getRootResume(
|
||||
{ ...request, currentUserId: "owner-id" },
|
||||
{ ...deps, findTarget: async () => target },
|
||||
);
|
||||
expect(result).toEqual({ status: "unavailable", canonicalUrl: "https://resume.example/" });
|
||||
expect(deps.getBySlug).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves only configured ID and delegates once with public-only enforcement", async () => {
|
||||
const deps = fixture();
|
||||
const result = await getRootResume({ ...request, currentUserId: "owner-id" }, deps);
|
||||
expect(result).toMatchObject({
|
||||
status: "public",
|
||||
username: "owner",
|
||||
slug: "current-slug",
|
||||
canonicalUrl: "https://resume.example/",
|
||||
resume: { showDownloadButtons: false, hasPassword: false },
|
||||
});
|
||||
expect(deps.findTarget).toHaveBeenCalledExactlyOnceWith("root-id");
|
||||
expect(deps.getBySlug).toHaveBeenCalledExactlyOnceWith({
|
||||
...request,
|
||||
currentUserId: "owner-id",
|
||||
username: "owner",
|
||||
slug: "current-slug",
|
||||
requirePublic: true,
|
||||
expectedResumeId: "root-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the renamed slug on the next request", async () => {
|
||||
const deps = fixture();
|
||||
deps.findTarget.mockResolvedValue({ username: "renamed-owner", slug: "renamed-slug", isPublic: true });
|
||||
expect(await getRootResume(request, deps)).toMatchObject({ username: "renamed-owner", slug: "renamed-slug" });
|
||||
});
|
||||
|
||||
it("keeps a privacy change during the final lookup unavailable", async () => {
|
||||
const deps = fixture();
|
||||
deps.getBySlug.mockRejectedValue(new ORPCError("NOT_FOUND"));
|
||||
expect(await getRootResume(request, deps)).toEqual({
|
||||
status: "unavailable",
|
||||
canonicalUrl: "https://resume.example/",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the existing password challenge identity", async () => {
|
||||
const deps = fixture();
|
||||
deps.getBySlug.mockRejectedValue(
|
||||
new ORPCError("NEED_PASSWORD", { status: 401, data: { username: "owner", slug: "current-slug" } }),
|
||||
);
|
||||
await expect(getRootResume(request, deps)).rejects.toMatchObject({
|
||||
code: "NEED_PASSWORD",
|
||||
data: { username: "owner", slug: "current-slug" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not hide infrastructure failures as missing resumes", async () => {
|
||||
const deps = fixture();
|
||||
deps.getBySlug.mockRejectedValue(new Error("database unavailable"));
|
||||
await expect(getRootResume(request, deps)).rejects.toThrow("database unavailable");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { resumeService } from "./service";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
type RootRequest = { requestHeaders: Headers; currentUserId?: string };
|
||||
type RootDependencies = {
|
||||
config: { rootResumeId?: string | undefined; appUrl: string };
|
||||
findTarget(id: string): Promise<{ username: string; slug: string; isPublic: boolean } | null>;
|
||||
getBySlug: typeof resumeService.getBySlug;
|
||||
};
|
||||
|
||||
const getDependencies = async (): Promise<RootDependencies> => {
|
||||
const [{ env }, { db }, schema, { eq }, { resumeService }] = await Promise.all([
|
||||
import("@reactive-resume/env/server"),
|
||||
import("@reactive-resume/db/client"),
|
||||
import("@reactive-resume/db/schema"),
|
||||
import("drizzle-orm"),
|
||||
import("./service"),
|
||||
]);
|
||||
return {
|
||||
config: { rootResumeId: env.ROOT_RESUME_ID, appUrl: env.APP_URL },
|
||||
findTarget: async (id) => {
|
||||
const [target] = await db
|
||||
.select({ username: schema.user.username, slug: schema.resume.slug, isPublic: schema.resume.isPublic })
|
||||
.from(schema.resume)
|
||||
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
|
||||
.where(eq(schema.resume.id, id));
|
||||
return target ?? null;
|
||||
},
|
||||
getBySlug: resumeService.getBySlug,
|
||||
};
|
||||
};
|
||||
|
||||
/** Instance configuration is the sole authority; callers cannot choose a host or target. */
|
||||
export async function getRootResume(input: RootRequest, dependencies?: RootDependencies) {
|
||||
const { config, findTarget, getBySlug } = dependencies ?? (await getDependencies());
|
||||
const id = config.rootResumeId?.trim();
|
||||
if (!id) return { status: "disabled" as const };
|
||||
|
||||
const canonicalUrl = new URL("/", config.appUrl).href;
|
||||
const unavailable = { status: "unavailable" as const, canonicalUrl };
|
||||
const target = await findTarget(id);
|
||||
if (!target?.isPublic) return unavailable;
|
||||
|
||||
try {
|
||||
const resume = await getBySlug({
|
||||
...input,
|
||||
username: target.username,
|
||||
slug: target.slug,
|
||||
requirePublic: true,
|
||||
expectedResumeId: id,
|
||||
});
|
||||
return { status: "public" as const, canonicalUrl, username: target.username, slug: target.slug, resume };
|
||||
} catch (error) {
|
||||
if (error instanceof ORPCError && error.code === "NOT_FOUND") return unavailable;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import { publicProcedure } from "../../context";
|
||||
import { crudRouter } from "./crud";
|
||||
import { updatesRouter } from "./event-router";
|
||||
import { getRootResume } from "./root";
|
||||
import { sharingRouter } from "./sharing";
|
||||
import { resumeStatisticsRouter } from "./statistics";
|
||||
import { tagsRouter } from "./tags";
|
||||
import { versionsRouter } from "./versions";
|
||||
|
||||
export const resumeRouter = {
|
||||
getRoot: publicProcedure.handler(({ context }) =>
|
||||
getRootResume({
|
||||
requestHeaders: context.reqHeaders,
|
||||
...(context.user?.id ? { currentUserId: context.user.id } : {}),
|
||||
}),
|
||||
),
|
||||
tags: tagsRouter,
|
||||
statistics: resumeStatisticsRouter,
|
||||
updates: updatesRouter,
|
||||
|
||||
@@ -1096,3 +1096,47 @@ describe("statistics.recordDownload", () => {
|
||||
expect(values).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("root public-only lookup", () => {
|
||||
it("rejects a private target even for its owner after identity resolution", async () => {
|
||||
const row = {
|
||||
...createResumeRow(defaultResumeData),
|
||||
userId: "u1",
|
||||
isPublic: false,
|
||||
hasPassword: false,
|
||||
passwordHash: null,
|
||||
};
|
||||
dbMock.select.mockReturnValue({ from: () => ({ innerJoin: () => ({ where: async () => [row] }) }) });
|
||||
await expect(
|
||||
resumeService.getBySlug({
|
||||
username: "owner",
|
||||
slug: "resume",
|
||||
requestHeaders: new Headers(),
|
||||
currentUserId: "u1",
|
||||
requirePublic: true,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a different resume reusing the resolved root slug", async () => {
|
||||
const row = {
|
||||
...createResumeRow(defaultResumeData),
|
||||
id: "replacement-id",
|
||||
userId: "u1",
|
||||
isPublic: true,
|
||||
hasPassword: false,
|
||||
passwordHash: null,
|
||||
};
|
||||
dbMock.select.mockReturnValue({ from: () => ({ innerJoin: () => ({ where: async () => [row] }) }) });
|
||||
await expect(
|
||||
resumeService.getBySlug({
|
||||
username: "owner",
|
||||
slug: "resume",
|
||||
requestHeaders: new Headers(),
|
||||
currentUserId: "u1",
|
||||
requirePublic: true,
|
||||
expectedResumeId: "configured-id",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
@@ -512,7 +512,14 @@ export const resumeService = {
|
||||
return resume;
|
||||
},
|
||||
|
||||
getBySlug: async (input: { username: string; slug: string; requestHeaders: Headers; currentUserId?: string }) => {
|
||||
getBySlug: async (input: {
|
||||
username: string;
|
||||
slug: string;
|
||||
requestHeaders: Headers;
|
||||
currentUserId?: string;
|
||||
requirePublic?: boolean;
|
||||
expectedResumeId?: string;
|
||||
}) => {
|
||||
const [resume] = await db
|
||||
.select({
|
||||
id: schema.resume.id,
|
||||
@@ -531,7 +538,12 @@ export const resumeService = {
|
||||
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
|
||||
.where(and(eq(schema.resume.slug, input.slug), eq(schema.user.username, input.username)));
|
||||
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
if (
|
||||
!resume ||
|
||||
(input.requirePublic && !resume.isPublic) ||
|
||||
(input.expectedResumeId && resume.id !== input.expectedResumeId)
|
||||
)
|
||||
throw new ORPCError("NOT_FOUND");
|
||||
|
||||
const viewer = input.currentUserId ? { id: input.currentUserId } : null;
|
||||
assertCanView(resume, viewer);
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@reactive-resume/utils/monorepo.node", () => ({ findWorkspaceRoot: () => undefined }));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("root resume configuration", () => {
|
||||
it.each([
|
||||
[undefined, undefined],
|
||||
["", undefined],
|
||||
[" ", undefined],
|
||||
[" root-id ", "root-id"],
|
||||
])("normalizes %s to %s", async (value, expected) => {
|
||||
vi.stubEnv("APP_URL", "https://resume.example");
|
||||
vi.stubEnv("DATABASE_URL", "postgresql://localhost/disposable");
|
||||
vi.stubEnv("AUTH_SECRET", "disposable");
|
||||
vi.stubEnv("ROOT_RESUME_ID", value);
|
||||
const { env } = await import("./server");
|
||||
expect(env.ROOT_RESUME_ID).toBe(expected);
|
||||
});
|
||||
});
|
||||
Vendored
+5
@@ -19,6 +19,11 @@ export const env = createEnv({
|
||||
server: {
|
||||
// Application
|
||||
APP_URL: z.url({ protocol: /https?/ }),
|
||||
ROOT_RESUME_ID: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value || undefined)
|
||||
.optional(),
|
||||
SERVER_PORT: z.coerce.number().int().min(1).max(65535).default(3001),
|
||||
|
||||
// Database
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { BrowserContext } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { Pool } from "pg";
|
||||
import { createAuthenticatedContext } from "../fixtures/auth";
|
||||
import { createAccount } from "../fixtures/data";
|
||||
import { deleteE2EUser } from "../fixtures/db";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
const rootId = process.env.ROOT_RESUME_ID?.trim();
|
||||
|
||||
test("unset root mode retains marketing and ordinary app entry points", async ({ page, request }) => {
|
||||
test.skip(Boolean(rootId), "Run once with ROOT_RESUME_ID unset, then restart with an e2e- ID.");
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("main")).toHaveAttribute("id", "main-content");
|
||||
await expect(page.locator('script[type="application/ld+json"]')).not.toHaveCount(0);
|
||||
expect((await request.get("/api/health")).ok()).toBe(true);
|
||||
await page.goto("/auth/login");
|
||||
await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("configured root preserves access, canonical, identity, statistics and app routes", async ({
|
||||
browser,
|
||||
authPage: owner,
|
||||
account,
|
||||
baseURL,
|
||||
}, testInfo) => {
|
||||
test.skip(!rootId, "Requires a disposable database and ROOT_RESUME_ID=e2e-root-2669.");
|
||||
test.setTimeout(120_000);
|
||||
if (!rootId?.startsWith("e2e-")) throw new Error("ROOT_RESUME_ID must begin with e2e- for this disposable fixture.");
|
||||
if (!baseURL) throw new Error("APP_URL is required for root E2E.");
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const visitor = await browser.newContext({ baseURL, userAgent: "root-resume-e2e-visitor" });
|
||||
const page = await visitor.newPage();
|
||||
const failures: string[] = [];
|
||||
const fontStatuses: number[] = [];
|
||||
const assetStatuses: number[] = [];
|
||||
page.on("response", (response) => {
|
||||
if (/\.(woff2?|ttf|otf)(?:\?|$)/.test(response.url())) fontStatuses.push(response.status());
|
||||
if (new URL(response.url()).pathname.startsWith("/assets/")) assetStatuses.push(response.status());
|
||||
});
|
||||
const secondAccount = createAccount(testInfo);
|
||||
let other: BrowserContext | undefined;
|
||||
const otherBootstrap = await browser.newContext({ baseURL });
|
||||
page.on("pageerror", (error) => failures.push(error.message));
|
||||
const unavailable = async () => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("We couldn't find that page", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Download PDF" })).toHaveCount(0);
|
||||
await expect(page.locator("body")).not.toContainText("Root Fixture");
|
||||
await expect(page.locator("body")).not.toContainText(rootId);
|
||||
await expect(page.getByRole("main")).toHaveCount(1);
|
||||
await expect(page.getByRole("navigation", { name: "Main navigation" })).toHaveCount(0);
|
||||
};
|
||||
try {
|
||||
await unavailable();
|
||||
other = await createAuthenticatedContext(browser, otherBootstrap.request, secondAccount, baseURL);
|
||||
const otherCreated = await other.request.post("/api/openapi/resumes", {
|
||||
data: { name: "Other public fixture", slug: "other-public", tags: [], withSampleData: true },
|
||||
});
|
||||
expect(otherCreated.ok()).toBe(true);
|
||||
const otherResumeId = await otherCreated.json();
|
||||
expect((await other.request.put(`/api/openapi/resumes/${otherResumeId}`, { data: { isPublic: true } })).ok()).toBe(
|
||||
true,
|
||||
);
|
||||
const created = await owner.request.post("/api/openapi/resumes", {
|
||||
data: { name: "Root Fixture", slug: "root-fixture", tags: [], withSampleData: true },
|
||||
});
|
||||
expect(created.ok()).toBe(true);
|
||||
const originalId = await created.json();
|
||||
await pool.query(
|
||||
'update resume set id = $1 where id = $2 and user_id = (select id from "user" where username = $3)',
|
||||
[rootId, originalId, account.username],
|
||||
);
|
||||
await pool.query(`update resume set data = jsonb_set(data, '{basics,name}', '"Root Fixture"') where id = $1`, [
|
||||
rootId,
|
||||
]);
|
||||
const resource = `/api/openapi/resumes/${rootId}`;
|
||||
const update = async (data: Record<string, unknown>) => {
|
||||
const response = await owner.request.put(resource, { data });
|
||||
expect(response.ok()).toBe(true);
|
||||
};
|
||||
const readStatistics = async () => (await owner.request.get(`${resource}/statistics`)).json();
|
||||
await unavailable();
|
||||
await owner.goto("/");
|
||||
await expect(owner.getByText("We couldn't find that page", { exact: true })).toBeVisible();
|
||||
const otherRoot = await other.request.post("/api/rpc/resume/getRoot", { data: { json: null } });
|
||||
expect(await otherRoot.json()).toEqual({ json: { status: "unavailable", canonicalUrl: `${baseURL}/` } });
|
||||
await owner.setViewportSize({ width: 1920, height: 950 });
|
||||
await owner.goto(`/builder/${rootId}`);
|
||||
await expect(owner.locator("#sidebar-picture")).toBeVisible();
|
||||
await owner.getByRole("button", { name: "Contain", exact: true }).click();
|
||||
const pictureData = await owner.evaluate(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas unavailable");
|
||||
context.fillStyle = "#4488aa";
|
||||
context.fillRect(0, 0, 64, 64);
|
||||
return canvas.toDataURL("image/png").slice("data:image/png;base64,".length);
|
||||
});
|
||||
await owner
|
||||
.locator('#sidebar-picture input[type="file"]')
|
||||
.setInputFiles({ name: "root-fixture.png", mimeType: "image/png", buffer: Buffer.from(pictureData, "base64") });
|
||||
const pictureInput = owner.locator('#sidebar-picture input[name="url"]');
|
||||
await expect(pictureInput).toHaveValue(/\/uploads\//);
|
||||
const pictureUrl = await pictureInput.inputValue();
|
||||
const pictureResponse = await visitor.request.get(pictureUrl);
|
||||
expect(pictureResponse.ok()).toBe(true);
|
||||
expect(pictureResponse.headers()["content-type"]).toMatch(/^image\//);
|
||||
await owner.goto("/");
|
||||
await expect(owner.getByText("We couldn't find that page", { exact: true })).toBeVisible();
|
||||
await update({ isPublic: true });
|
||||
await owner.reload();
|
||||
await expect(owner.getByRole("button", { name: "Download PDF" }).first()).toBeVisible();
|
||||
expect(await readStatistics()).toMatchObject({ views: 0, downloads: 0 });
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Download PDF" }).first()).toBeVisible();
|
||||
await expect(page.locator("canvas").first()).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Root Fixture", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("root-public.png"), fullPage: true });
|
||||
await expect(page.locator("header img")).toHaveAttribute("src", pictureUrl);
|
||||
await expect(page).toHaveURL(`${baseURL}/`);
|
||||
await expect(page.getByRole("main")).toHaveCount(1);
|
||||
await expect(page.getByRole("navigation", { name: "Main navigation" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Build your own resume" })).toHaveAttribute("href", "/dashboard");
|
||||
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute("href", `${baseURL}/`);
|
||||
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(0);
|
||||
await expect.poll(readStatistics).toMatchObject({ views: 1, downloads: 0 });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Download PDF" }).first()).toBeVisible();
|
||||
expect(await readStatistics()).toMatchObject({ views: 1, downloads: 0 });
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download PDF" }).first().click();
|
||||
const download = await downloadPromise;
|
||||
expect(await download.failure()).toBeNull();
|
||||
const downloadPath = await download.path();
|
||||
if (!downloadPath) throw new Error("Missing root PDF download");
|
||||
expect((await readFile(downloadPath)).subarray(0, 5).toString()).toBe("%PDF-");
|
||||
await expect.poll(readStatistics).toMatchObject({ views: 1, downloads: 1 });
|
||||
|
||||
await update({ slug: "renamed-root", showDownloadButtons: false });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Download PDF" })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(`${baseURL}/`);
|
||||
const slugPath = `/${account.username}/renamed-root`;
|
||||
await page.goto(slugPath);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
await expect(page.locator('link[rel="canonical"]').last()).toHaveAttribute("href", `${baseURL}${slugPath}`);
|
||||
await update({ showDownloadButtons: true });
|
||||
await page.goto("/ats-checker");
|
||||
await page.getByRole("link", { name: "Reactive Resume - Go to homepage", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Root Fixture", exact: true })).toBeVisible();
|
||||
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute("href", `${baseURL}/`);
|
||||
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(0);
|
||||
|
||||
const password = "root-e2e-secret";
|
||||
expect((await owner.request.put(`${resource}/password`, { data: { password } })).ok()).toBe(true);
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(/\/auth\/resume-password/);
|
||||
expect(new URL(page.url()).searchParams.get("returnTo")).toBe("/");
|
||||
await page.getByLabel("Password", { exact: true }).fill("wrong-password");
|
||||
await page.getByRole("button", { name: "Unlock", exact: true }).click();
|
||||
await expect(page.getByText("The password you entered is incorrect", { exact: true })).toBeVisible();
|
||||
const lockedPdf = await visitor.request.get(`/api/resumes/${account.username}/renamed-root/pdf`);
|
||||
expect(lockedPdf.status()).toBe(401);
|
||||
await page.getByLabel("Password", { exact: true }).fill(password);
|
||||
await page.getByRole("button", { name: "Unlock", exact: true }).click();
|
||||
await expect(page).toHaveURL(`${baseURL}/`);
|
||||
await expect(page.getByRole("button", { name: "Download PDF" }).first()).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(`${baseURL}/`);
|
||||
await expect(page.getByRole("button", { name: "Download PDF" }).first()).toBeVisible();
|
||||
const accessCookies = (await visitor.cookies()).filter((cookie) => cookie.name.startsWith("resume_access_"));
|
||||
expect(accessCookies.length).toBeGreaterThan(0);
|
||||
await visitor.addCookies(
|
||||
accessCookies.map((cookie) => ({ ...cookie, expires: Math.floor(Date.now() / 1000) - 1 })),
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/auth\/resume-password/);
|
||||
expect((await visitor.request.get(`/api/resumes/${account.username}/renamed-root/pdf`)).status()).toBe(401);
|
||||
|
||||
await update({ isPublic: false });
|
||||
await unavailable();
|
||||
await owner.goto("/");
|
||||
await expect(owner.getByText("We couldn't find that page", { exact: true })).toBeVisible();
|
||||
const rootResponse = await visitor.request.post("/api/rpc/resume/getRoot", {
|
||||
data: {
|
||||
json: {
|
||||
id: otherResumeId,
|
||||
username: secondAccount.username,
|
||||
slug: "other-public",
|
||||
host: "evil.example",
|
||||
requirePublic: false,
|
||||
},
|
||||
},
|
||||
headers: { "x-forwarded-host": "evil.example" },
|
||||
});
|
||||
expect(rootResponse.ok()).toBe(true);
|
||||
expect(await rootResponse.json()).toEqual({ json: { status: "unavailable", canonicalUrl: `${baseURL}/` } });
|
||||
const shell = await visitor.request.get("/", {
|
||||
headers: { host: "attacker.example", "x-forwarded-host": "evil.example" },
|
||||
});
|
||||
expect(await shell.text()).toContain(`<link rel="canonical" href="${baseURL}/" data-root-resume-shell>`);
|
||||
expect(shell.headers()["x-robots-tag"]).toBe("noindex, follow");
|
||||
for (const path of ["/api/health", "/auth/login", "/favicon.svg"]) {
|
||||
const response = await visitor.request.get(path);
|
||||
expect(response.ok(), path).toBe(true);
|
||||
}
|
||||
await page.goto("/ats-checker");
|
||||
await expect(page.getByRole("navigation", { name: "Main navigation" })).toBeVisible();
|
||||
await page.goto("/auth/login");
|
||||
await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
|
||||
await owner.goto("/dashboard/resumes");
|
||||
await expect(owner.getByText("Root Fixture", { exact: true }).first()).toBeVisible();
|
||||
expect(failures).toEqual([]);
|
||||
expect(fontStatuses.length).toBeGreaterThan(0);
|
||||
expect(assetStatuses.length).toBeGreaterThan(0);
|
||||
expect([...fontStatuses, ...assetStatuses].every((status) => status >= 200 && status < 400)).toBe(true);
|
||||
expect((await owner.request.delete(resource)).ok()).toBe(true);
|
||||
await unavailable();
|
||||
} finally {
|
||||
await visitor.close();
|
||||
await other?.close();
|
||||
await otherBootstrap.close();
|
||||
await deleteE2EUser(secondAccount);
|
||||
await pool.end();
|
||||
}
|
||||
});
|
||||
@@ -48,6 +48,7 @@
|
||||
"PORT",
|
||||
"SERVER_PORT",
|
||||
"APP_URL",
|
||||
"ROOT_RESUME_ID",
|
||||
"DATABASE_URL",
|
||||
"AUTH_SECRET",
|
||||
"BETTER_AUTH_API_KEY",
|
||||
|
||||
Reference in New Issue
Block a user