ADDED - Admin SEO page, robots/sitemap hardening and media/maintenance security fixes
SEO - New Settings > SEO admin page (seo_settings in app_config): indexing switch, Google/Bing verification, X handle, JSON-LD identity (Person/Organization, sameAs), per-locale keywords, readiness checklist and open links for sitemap.xml / robots.txt / manifest. - robots.txt is now dynamic: disallows admin, api, success and coming-soon paths; blocks everything while indexing is off or maintenance is on. - sitemap.xml carries hreflang alternates per URL, lists only categories with published projects, and is empty while hidden. - Metadata: robots + verification meta, og:locale in de_DE/en_US/ar_AR form, alternateLocale, twitter site/creator, project cover as OG image with article type, noindex on /success and /coming-soon. - JSON-LD: WebSite + publisher graph on all public pages, CreativeWork per project (view-mode independent). Security - Maintenance bypass now requires a correctly signed admin cookie; the middleware previously only checked the cookie existed. Token helpers moved to lib/admin-session-token.ts (shared by proxy.ts and lib/admin-auth.ts). - Media uploads: magic-byte validation against the declared type, SVG sanitization (script/handlers/foreignObject/javascript: rejected), upload folder sanitized, kind inferred from the real file. - Media route: fixed prefix-based path check that accepted sibling directories, unknown extensions return 404, nosniff header, CSP sandbox on SVG, gif content type added. - External media URLs: protocol-relative (//host) URLs rejected. Portfolio - Project and category slugs share /portfolio/[slug]; saving now rejects a slug already used on the other side instead of silently shadowing it. Tooling/docs - Lint: ignore scripts/legacy-prisma-seed.cjs, drop unused import. - New docs/SEO.md; FEATURES, ARCHITECTURE (Drizzle instead of Prisma), admin spec and CLAUDE.md updated. - Tests for all of the above (unit + integration); suite green.
This commit is contained in:
@@ -32,7 +32,7 @@ describe("createMediaAssetAction", () => {
|
||||
});
|
||||
|
||||
it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => {
|
||||
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "pic.png", { type: "image/png" });
|
||||
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], "pic.png", { type: "image/png" });
|
||||
const url = await captureRedirect(() =>
|
||||
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
|
||||
);
|
||||
@@ -43,6 +43,15 @@ describe("createMediaAssetAction", () => {
|
||||
await removeManagedMediaFile(assets[0].url);
|
||||
});
|
||||
|
||||
it("rejects an upload whose bytes do not match the declared image type", async () => {
|
||||
const file = new File(["<html><script>alert(1)</script></html>"], "evil.png", { type: "image/png" });
|
||||
const url = await captureRedirect(() =>
|
||||
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Evil", file })),
|
||||
);
|
||||
expect(url).toContain("error=");
|
||||
expect((await db.select().from(mediaAsset)).length).toBe(0);
|
||||
});
|
||||
|
||||
it("redirects unauthenticated callers to the admin root", async () => {
|
||||
adminAuth.authenticated = false;
|
||||
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
|
||||
|
||||
@@ -104,6 +104,14 @@ describe("upsertCategoryAction", () => {
|
||||
expect(category?.nameEn).toBe("Renamed");
|
||||
});
|
||||
|
||||
it("rejects a category slug that already belongs to a project", async () => {
|
||||
const other = await createCategory({ slug: "other" });
|
||||
await createProject({ categoryId: other.id, slug: "taken" });
|
||||
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "taken" })));
|
||||
expect(new URL(url, "http://test").searchParams.get("error")).toContain("Projekt Slug vergeben");
|
||||
expect(await db.query.category.findFirst({ where: eq(categoryTable.slug, "taken") })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a unique-constraint violation on duplicate slugs", async () => {
|
||||
await createCategory({ slug: "branding" });
|
||||
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" })));
|
||||
@@ -141,6 +149,13 @@ describe("deleteCategoryAction", () => {
|
||||
});
|
||||
|
||||
describe("saveProjectAction", () => {
|
||||
it("rejects a project slug that already belongs to a category", async () => {
|
||||
const category = await createCategory({ slug: "branding" });
|
||||
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { slug: "branding" })));
|
||||
expect(new URL(url, "http://test").searchParams.get("error")).toContain("Kategorie Slug vergeben");
|
||||
expect(await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "branding") })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates a published project with cover and asset media usages", async () => {
|
||||
const category = await createCategory();
|
||||
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
|
||||
|
||||
@@ -11,10 +11,11 @@ vi.mock("@/lib/admin-auth", async () => {
|
||||
});
|
||||
|
||||
import {
|
||||
saveSeoSettingsAction,
|
||||
saveSiteBrandSettingsAction,
|
||||
saveSiteLocalizationSettingsAction,
|
||||
} from "@/app/_admin/site-settings/actions";
|
||||
import { getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
||||
import { getSeoSettings, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
||||
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -102,3 +103,51 @@ describe("saveSiteLocalizationSettingsAction", () => {
|
||||
expect(url).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveSeoSettingsAction", () => {
|
||||
it("persists normalized seo settings", async () => {
|
||||
const url = await captureRedirect(() =>
|
||||
saveSeoSettingsAction(
|
||||
formDataFrom({
|
||||
allowIndexing: "on",
|
||||
googleSiteVerification: "g-1",
|
||||
twitterHandle: "moh",
|
||||
structuredDataType: "Organization",
|
||||
structuredDataName: "Studio",
|
||||
sameAs: "https://a.com\nhttps://b.com",
|
||||
keywordsDe: "a, b",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(url).toContain("success=");
|
||||
const seo = await getSeoSettings();
|
||||
expect(seo).toMatchObject({
|
||||
allowIndexing: true,
|
||||
googleSiteVerification: "g-1",
|
||||
twitterHandle: "@moh",
|
||||
structuredDataType: "Organization",
|
||||
sameAs: ["https://a.com", "https://b.com"],
|
||||
});
|
||||
expect(seo.locales.de.keywords).toBe("a, b");
|
||||
});
|
||||
|
||||
it("turns indexing off when the checkbox is missing", async () => {
|
||||
await captureRedirect(() => saveSeoSettingsAction(formDataFrom({})));
|
||||
expect((await getSeoSettings()).allowIndexing).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an invalid verification token without saving", async () => {
|
||||
await captureRedirect(() => saveSeoSettingsAction(formDataFrom({ allowIndexing: "on", googleSiteVerification: "ok" })));
|
||||
const url = await captureRedirect(() =>
|
||||
saveSeoSettingsAction(formDataFrom({ allowIndexing: "on", googleSiteVerification: "<bad>" })),
|
||||
);
|
||||
expect(url).toContain("error=");
|
||||
expect((await getSeoSettings()).googleSiteVerification).toBe("ok");
|
||||
});
|
||||
|
||||
it("redirects unauthenticated users", async () => {
|
||||
adminAuth.authenticated = false;
|
||||
const url = await captureRedirect(() => saveSeoSettingsAction(formDataFrom({})));
|
||||
expect(url).not.toContain("success=");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("resolveMediaSelection — missing configuration", () => {
|
||||
|
||||
describe("resolveMediaSelection — upload mode (filesystem)", () => {
|
||||
it.skipIf(!canManageUploads)("saves the file and creates an UPLOAD asset", async () => {
|
||||
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "shot.png", { type: "image/png" });
|
||||
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], "shot.png", { type: "image/png" });
|
||||
const result = await resolveMediaSelection({
|
||||
media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" },
|
||||
uploadFile: file,
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("metadata helpers", () => {
|
||||
apple: [{ url: "/apple-icon.png?v=v1" }],
|
||||
});
|
||||
expect(metadata.openGraph).toMatchObject({
|
||||
locale: "ar",
|
||||
locale: "ar_AR",
|
||||
url: "https://mohfarawati.de/",
|
||||
});
|
||||
expect(metadata.twitter).toMatchObject({
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("../lib/admin-routing", () => ({
|
||||
toInternalAdminPath: (pathname: string) => pathname,
|
||||
}));
|
||||
|
||||
function createMockRequest(url: string) {
|
||||
function createMockRequest(url: string, cookieValue?: string) {
|
||||
const nextUrl = new URL(url) as URL & { clone: () => URL };
|
||||
nextUrl.clone = () => new URL(nextUrl.toString());
|
||||
|
||||
@@ -31,11 +31,25 @@ function createMockRequest(url: string) {
|
||||
host: nextUrl.host,
|
||||
}),
|
||||
cookies: {
|
||||
has: vi.fn(() => false),
|
||||
has: vi.fn(() => cookieValue !== undefined),
|
||||
get: vi.fn(() => (cookieValue !== undefined ? { name: "moh_admin_session", value: cookieValue } : undefined)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stubMaintenanceRuntime() {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
defaultLocale: "de",
|
||||
maintenanceEnabled: true,
|
||||
}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
describe("middleware locale runtime config", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@@ -123,6 +137,28 @@ describe("middleware locale runtime config", () => {
|
||||
expect(intlHandlerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let a forged admin cookie bypass maintenance mode", async () => {
|
||||
vi.stubEnv("ADMIN_AUTH_SECRET", "test-secret");
|
||||
stubMaintenanceRuntime();
|
||||
|
||||
const { default: middleware } = await import("../proxy");
|
||||
const response = await middleware(createMockRequest("https://example.com/about", "superadmin.forged") as never);
|
||||
|
||||
expect(response.headers.get("location")).toBe("https://example.com/coming-soon");
|
||||
expect(intlHandlerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a correctly signed admin cookie through maintenance mode", async () => {
|
||||
vi.stubEnv("ADMIN_AUTH_SECRET", "test-secret");
|
||||
stubMaintenanceRuntime();
|
||||
|
||||
const { buildAdminSessionToken } = await import("../lib/admin-session-token");
|
||||
const { default: middleware } = await import("../proxy");
|
||||
await middleware(createMockRequest("https://example.com/about", buildAdminSessionToken()) as never);
|
||||
|
||||
expect(intlHandlerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prefers the configured internal runtime origin when provided", async () => {
|
||||
process.env.SITE_RUNTIME_ORIGIN = "http://app:3000";
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("getAdminNavigation", () => {
|
||||
const minimal = { ...copy, brandSettings: undefined, localizationSettings: undefined, marquee: undefined, smtp: undefined };
|
||||
const nav = getAdminNavigation(minimal, "overview");
|
||||
const siteSettings = nav.find((item) => item.label === "Site Settings");
|
||||
expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization"]);
|
||||
expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization", "SEO"]);
|
||||
expect(nav.find((item) => item.href.endsWith("/marquee"))?.label).toBe("Marquee");
|
||||
expect(nav.find((item) => item.href.endsWith("/smtp"))?.label).toBe("SMTP");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { buildAdminSessionToken, verifyAdminSessionToken } from "@/lib/admin-session-token";
|
||||
|
||||
const originalSecret = process.env.ADMIN_AUTH_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ADMIN_AUTH_SECRET = "unit-test-secret";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ADMIN_AUTH_SECRET = originalSecret;
|
||||
});
|
||||
|
||||
describe("admin session token", () => {
|
||||
it("round-trips a signed token", () => {
|
||||
expect(verifyAdminSessionToken(buildAdminSessionToken())).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects forged, malformed, or missing tokens", () => {
|
||||
expect(verifyAdminSessionToken("superadmin")).toBe(false);
|
||||
expect(verifyAdminSessionToken("superadmin.deadbeef")).toBe(false);
|
||||
expect(verifyAdminSessionToken("other." + buildAdminSessionToken().split(".")[1])).toBe(false);
|
||||
expect(verifyAdminSessionToken("")).toBe(false);
|
||||
expect(verifyAdminSessionToken(undefined)).toBe(false);
|
||||
expect(verifyAdminSessionToken("1")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a token signed with a different secret", () => {
|
||||
const token = buildAdminSessionToken();
|
||||
process.env.ADMIN_AUTH_SECRET = "rotated";
|
||||
expect(verifyAdminSessionToken(token)).toBe(false);
|
||||
});
|
||||
|
||||
it("never validates when no secret is configured", () => {
|
||||
process.env.ADMIN_AUTH_SECRET = "";
|
||||
expect(verifyAdminSessionToken("superadmin.anything")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MEDIA_UPLOAD_ROOT,
|
||||
getExtensionForMimeType,
|
||||
isManagedMediaFilePath,
|
||||
isMediaContentValid,
|
||||
removeManagedMediaFile,
|
||||
resolveMediaUploadPath,
|
||||
sanitizeBaseName,
|
||||
@@ -69,6 +70,42 @@ describe("resolveMediaUploadPath", () => {
|
||||
it("throws when a traversal attempt escapes the root", () => {
|
||||
expect(() => resolveMediaUploadPath("/uploads/media/../../etc/passwd")).toThrow(/escapes/i);
|
||||
});
|
||||
|
||||
it("rejects a sibling directory that merely shares the root prefix", () => {
|
||||
// `.../uploads/media-evil` starts with `.../uploads/media` as a string.
|
||||
expect(() => resolveMediaUploadPath("/uploads/media/../media-evil/x.png")).toThrow(/escapes/i);
|
||||
});
|
||||
|
||||
it("rejects the root itself, empty paths and null bytes", () => {
|
||||
expect(() => resolveMediaUploadPath("/uploads/media/")).toThrow(/escapes/i);
|
||||
expect(() => resolveMediaUploadPath("/uploads/media/./")).toThrow(/escapes/i);
|
||||
expect(() => resolveMediaUploadPath("/uploads/media/a\0.png")).toThrow(/escapes/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMediaContentValid", () => {
|
||||
it("accepts files whose magic bytes match the extension", () => {
|
||||
expect(isMediaContentValid(".png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]))).toBe(true);
|
||||
expect(isMediaContentValid(".jpg", Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe(true);
|
||||
expect(isMediaContentValid(".gif", Buffer.from("GIF89a"))).toBe(true);
|
||||
expect(isMediaContentValid(".pdf", Buffer.from("%PDF-1.7"))).toBe(true);
|
||||
expect(isMediaContentValid(".webp", Buffer.from("RIFF\0\0\0\0WEBPVP8 "))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects mismatched bytes (e.g. HTML disguised as an image)", () => {
|
||||
expect(isMediaContentValid(".png", Buffer.from("<html><script>alert(1)</script>"))).toBe(false);
|
||||
expect(isMediaContentValid(".jpg", Buffer.from("GIF89a"))).toBe(false);
|
||||
expect(isMediaContentValid(".exe", Buffer.from("MZ"))).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts plain svg and rejects active content", () => {
|
||||
expect(isMediaContentValid(".svg", Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'))).toBe(true);
|
||||
expect(isMediaContentValid(".svg", Buffer.from('<?xml version="1.0"?>\n<svg><circle/></svg>'))).toBe(true);
|
||||
expect(isMediaContentValid(".svg", Buffer.from("<svg><script>alert(1)</script></svg>"))).toBe(false);
|
||||
expect(isMediaContentValid(".svg", Buffer.from('<svg onload="alert(1)"></svg>'))).toBe(false);
|
||||
expect(isMediaContentValid(".svg", Buffer.from('<svg><a xlink:href="javascript:x"/></svg>'))).toBe(false);
|
||||
expect(isMediaContentValid(".svg", Buffer.from("<html><svg/></html>"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeManagedMediaFile", () => {
|
||||
|
||||
@@ -25,6 +25,11 @@ describe("mediaFieldInputSchema", () => {
|
||||
expect(parsed.url).toBe("/uploads/media/x.png");
|
||||
});
|
||||
|
||||
it("rejects protocol-relative and javascript urls", () => {
|
||||
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external", url: "//evil.com/x.png" })).toThrow(/URL/i);
|
||||
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external", url: "javascript:alert(1)" })).toThrow(/URL/i);
|
||||
});
|
||||
|
||||
it("requires a url in external mode", () => {
|
||||
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external" })).toThrow(/URL/i);
|
||||
});
|
||||
|
||||
+106
-1
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildDefaultSeoSettings } from "@/lib/seo-settings";
|
||||
import { buildDefaultSiteSettings } from "@/lib/site-settings";
|
||||
import {
|
||||
applyTitleTemplateFn,
|
||||
buildAppMetadataFromConfig,
|
||||
buildLocaleAlternates,
|
||||
buildLocalizedMetadataFromConfig,
|
||||
buildProjectJsonLd,
|
||||
buildSiteJsonLd,
|
||||
serializeJsonLd,
|
||||
} from "@/lib/metadata";
|
||||
|
||||
const noBindings = {
|
||||
@@ -82,7 +86,7 @@ describe("buildLocalizedMetadataFromConfig", () => {
|
||||
});
|
||||
expect(metadata.title).toBe("About | Studio");
|
||||
expect(metadata.description).toBe("English description");
|
||||
expect(metadata.openGraph?.locale).toBe("en");
|
||||
expect(metadata.openGraph?.locale).toBe("en_US");
|
||||
});
|
||||
|
||||
it("can skip the title template (homepage)", () => {
|
||||
@@ -111,3 +115,104 @@ describe("buildLocalizedMetadataFromConfig", () => {
|
||||
expect(metadata.description).toBe("Custom desc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("seo-aware metadata", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio");
|
||||
|
||||
it("indexes by default and emits verification + twitter handle when configured", () => {
|
||||
const seo = {
|
||||
...buildDefaultSeoSettings(),
|
||||
googleSiteVerification: "g123",
|
||||
bingSiteVerification: "b456",
|
||||
twitterHandle: "@moh",
|
||||
};
|
||||
const metadata = buildAppMetadataFromConfig(settings, noBindings, seo);
|
||||
expect(metadata.robots).toMatchObject({ index: true, follow: true });
|
||||
expect(metadata.verification).toEqual({ google: "g123", other: { "msvalidate.01": "b456" } });
|
||||
expect(metadata.twitter).toMatchObject({ site: "@moh", creator: "@moh" });
|
||||
expect(metadata.openGraph).toMatchObject({ locale: "de_DE", alternateLocale: ["en_US", "ar_AR"] });
|
||||
});
|
||||
|
||||
it("emits noindex everywhere when indexing is disabled", () => {
|
||||
const seo = { ...buildDefaultSeoSettings(), allowIndexing: false };
|
||||
expect(buildAppMetadataFromConfig(settings, noBindings, seo).robots).toMatchObject({ index: false });
|
||||
const page = buildLocalizedMetadataFromConfig({ settings, bindings: noBindings, seo, locale: "en", pathname: "/about", title: "About" });
|
||||
expect(page.robots).toMatchObject({ index: false, follow: false });
|
||||
});
|
||||
|
||||
it("supports per-page noindex, article type and a page-specific image", () => {
|
||||
const published = new Date("2026-01-02T00:00:00Z");
|
||||
const page = buildLocalizedMetadataFromConfig({
|
||||
settings,
|
||||
bindings: { ...noBindings, defaultOgImage: { assetId: "a", url: "/og.png", version: "1" } },
|
||||
locale: "en",
|
||||
pathname: "/portfolio/x",
|
||||
title: "X",
|
||||
image: "/uploads/media/covers/x.png",
|
||||
type: "article",
|
||||
publishedTime: published,
|
||||
});
|
||||
expect(page.robots).toMatchObject({ index: true });
|
||||
expect(page.openGraph).toMatchObject({
|
||||
type: "article",
|
||||
publishedTime: published.toISOString(),
|
||||
images: [{ url: "https://mohfarawati.de/uploads/media/covers/x.png", alt: "X" }],
|
||||
});
|
||||
|
||||
const thanks = buildLocalizedMetadataFromConfig({ settings, bindings: noBindings, locale: "en", pathname: "/success", title: "Thanks", noIndex: true });
|
||||
expect(thanks.robots).toMatchObject({ index: false });
|
||||
});
|
||||
|
||||
it("falls back to the default og image when no page image is given", () => {
|
||||
const page = buildLocalizedMetadataFromConfig({
|
||||
settings,
|
||||
bindings: { ...noBindings, defaultOgImage: { assetId: "a", url: "/og.png", version: "1" } },
|
||||
locale: "de",
|
||||
pathname: "/",
|
||||
title: "Home",
|
||||
});
|
||||
expect(page.openGraph).toMatchObject({ images: [{ url: "https://mohfarawati.de/og.png", alt: "Home" }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("json-ld", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio");
|
||||
|
||||
it("builds a WebSite + Person graph linked by @id", () => {
|
||||
const seo = { ...buildDefaultSeoSettings(), structuredDataName: "Moh", structuredDataJobTitle: "Designer", sameAs: ["https://x.com/moh"] };
|
||||
const graph = buildSiteJsonLd({ settings, seo, bindings: noBindings, locale: "de" })["@graph"] as Array<Record<string, unknown>>;
|
||||
expect(graph[0]).toMatchObject({ "@type": "WebSite", publisher: { "@id": "https://mohfarawati.de/#person" } });
|
||||
expect(graph[1]).toMatchObject({ "@type": "Person", name: "Moh", jobTitle: "Designer", sameAs: ["https://x.com/moh"] });
|
||||
});
|
||||
|
||||
it("uses Organization shape when configured", () => {
|
||||
const seo = { ...buildDefaultSeoSettings(), structuredDataType: "Organization" as const, structuredDataJobTitle: "Studio" };
|
||||
const graph = buildSiteJsonLd({ settings, seo, bindings: noBindings, locale: "en" })["@graph"] as Array<Record<string, unknown>>;
|
||||
expect(graph[1]).toMatchObject({ "@type": "Organization", slogan: "Studio" });
|
||||
});
|
||||
|
||||
it("builds a CreativeWork per project with localized url", () => {
|
||||
const work = buildProjectJsonLd({
|
||||
settings,
|
||||
seo: buildDefaultSeoSettings(),
|
||||
locale: "en",
|
||||
pathname: "/portfolio/x",
|
||||
title: "X",
|
||||
description: "D",
|
||||
image: "/c.png",
|
||||
datePublished: new Date("2026-01-01T00:00:00Z"),
|
||||
clientName: "ACME",
|
||||
});
|
||||
expect(work).toMatchObject({
|
||||
"@type": "CreativeWork",
|
||||
url: "https://mohfarawati.de/en/portfolio/x",
|
||||
image: "https://mohfarawati.de/c.png",
|
||||
sourceOrganization: { "@type": "Organization", name: "ACME" },
|
||||
author: { "@id": "https://mohfarawati.de/#person" },
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes < so the payload cannot close the script tag", () => {
|
||||
expect(serializeJsonLd({ name: "</script><script>alert(1)</script>" })).not.toContain("</script>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ROBOTS_DISALLOWED_PATHS, buildRobots } from "@/app/robots";
|
||||
|
||||
describe("buildRobots", () => {
|
||||
it("blocks everything when not indexable and omits the sitemap", () => {
|
||||
const robots = buildRobots({ indexable: false });
|
||||
expect(robots.rules).toEqual([{ userAgent: "*", disallow: "/" }]);
|
||||
expect(robots.sitemap).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows crawling but hides admin, api and utility pages when indexable", () => {
|
||||
const robots = buildRobots({ indexable: true });
|
||||
const rule = Array.isArray(robots.rules) ? robots.rules[0] : robots.rules;
|
||||
expect(rule.allow).toBe("/");
|
||||
expect(rule.disallow).toEqual(ROBOTS_DISALLOWED_PATHS);
|
||||
expect(rule.disallow).toEqual(expect.arrayContaining(["/admin-internal", "/root", "/api/", "/success", "/coming-soon"]));
|
||||
expect(robots.sitemap).toBe("https://mohfarawati.de/sitemap.xml");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildSeoChecklist, summarizeSeoChecklist } from "@/lib/seo-report";
|
||||
import { buildDefaultSeoSettings } from "@/lib/seo-settings";
|
||||
import { buildDefaultSiteSettings, getDefaultSiteSettingsMediaBindings } from "@/lib/site-settings";
|
||||
|
||||
function run(overrides: Partial<Parameters<typeof buildSeoChecklist>[0]> = {}) {
|
||||
return buildSeoChecklist({
|
||||
seo: buildDefaultSeoSettings(),
|
||||
settings: buildDefaultSiteSettings(),
|
||||
bindings: getDefaultSiteSettingsMediaBindings(),
|
||||
maintenanceEnabled: false,
|
||||
publishedProjectCount: 2,
|
||||
sitemapEntryCount: 12,
|
||||
siteUrl: "https://mohfarawati.de",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildSeoChecklist", () => {
|
||||
it("flags maintenance mode as an indexing error", () => {
|
||||
const check = run({ maintenanceEnabled: true }).find((entry) => entry.id === "indexing");
|
||||
expect(check?.status).toBe("error");
|
||||
expect(check?.detail).toMatch(/Wartungsmodus/);
|
||||
});
|
||||
|
||||
it("flags disabled indexing and passes when enabled", () => {
|
||||
const seo = { ...buildDefaultSeoSettings(), allowIndexing: false };
|
||||
expect(run({ seo }).find((entry) => entry.id === "indexing")?.status).toBe("error");
|
||||
expect(run().find((entry) => entry.id === "indexing")?.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("warns about localhost as public url", () => {
|
||||
expect(run({ siteUrl: "http://localhost:3014" }).find((entry) => entry.id === "site-url")?.status).toBe("warn");
|
||||
});
|
||||
|
||||
it("grades description length per locale", () => {
|
||||
const settings = buildDefaultSiteSettings();
|
||||
settings.locales.de.siteDescription = "";
|
||||
settings.locales.en.siteDescription = "x".repeat(80);
|
||||
const checks = run({ settings });
|
||||
expect(checks.find((entry) => entry.id === "description-de")?.status).toBe("error");
|
||||
expect(checks.find((entry) => entry.id === "description-en")?.status).toBe("ok");
|
||||
expect(checks.find((entry) => entry.id === "description-ar")?.status).toBe("warn");
|
||||
});
|
||||
|
||||
it("summarizes counts", () => {
|
||||
const summary = summarizeSeoChecklist(run());
|
||||
expect(summary.ok + summary.warn + summary.error).toBe(run().length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildDefaultSeoSettings,
|
||||
normalizeKeywords,
|
||||
normalizeSameAs,
|
||||
normalizeTwitterHandle,
|
||||
normalizeVerificationToken,
|
||||
parseSeoSettingsValue,
|
||||
toOpenGraphLocale,
|
||||
} from "@/lib/seo-settings";
|
||||
|
||||
describe("parseSeoSettingsValue", () => {
|
||||
it("returns defaults for empty or invalid JSON", () => {
|
||||
expect(parseSeoSettingsValue(undefined)).toEqual(buildDefaultSeoSettings());
|
||||
expect(parseSeoSettingsValue("{not json")).toEqual(buildDefaultSeoSettings());
|
||||
expect(parseSeoSettingsValue(null).allowIndexing).toBe(true);
|
||||
});
|
||||
|
||||
it("only disables indexing on an explicit false", () => {
|
||||
expect(parseSeoSettingsValue(JSON.stringify({ allowIndexing: false })).allowIndexing).toBe(false);
|
||||
expect(parseSeoSettingsValue(JSON.stringify({ allowIndexing: "no" })).allowIndexing).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes every field and drops junk", () => {
|
||||
const parsed = parseSeoSettingsValue(
|
||||
JSON.stringify({
|
||||
googleSiteVerification: "abc-123_XYZ",
|
||||
bingSiteVerification: "<script>",
|
||||
twitterHandle: "https://x.com/moh_farawati",
|
||||
structuredDataType: "Company",
|
||||
sameAs: ["https://behance.net/x", "http://insecure", "javascript:alert(1)", "https://behance.net/x"],
|
||||
locales: { de: { keywords: " a , ,b,, c " } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed.googleSiteVerification).toBe("abc-123_XYZ");
|
||||
expect(parsed.bingSiteVerification).toBe("");
|
||||
expect(parsed.twitterHandle).toBe("@moh_farawati");
|
||||
expect(parsed.structuredDataType).toBe("Person");
|
||||
expect(parsed.sameAs).toEqual(["https://behance.net/x"]);
|
||||
expect(parsed.locales.de.keywords).toBe("a, b, c");
|
||||
expect(parsed.locales.en.keywords).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizers", () => {
|
||||
it("rejects verification tokens with unsafe characters", () => {
|
||||
expect(normalizeVerificationToken("ok_token-1")).toBe("ok_token-1");
|
||||
expect(normalizeVerificationToken('x" onload="1')).toBe("");
|
||||
expect(normalizeVerificationToken(42)).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes twitter handles with or without @ / URL", () => {
|
||||
expect(normalizeTwitterHandle("@moh")).toBe("@moh");
|
||||
expect(normalizeTwitterHandle("moh")).toBe("@moh");
|
||||
expect(normalizeTwitterHandle("https://twitter.com/moh")).toBe("@moh");
|
||||
expect(normalizeTwitterHandle("this-has-dashes")).toBe("");
|
||||
expect(normalizeTwitterHandle("a".repeat(16))).toBe("");
|
||||
});
|
||||
|
||||
it("accepts newline or comma separated https URLs only", () => {
|
||||
expect(normalizeSameAs("https://a.com\nhttps://b.com, ftp://c")).toEqual(["https://a.com", "https://b.com"]);
|
||||
expect(normalizeSameAs(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps keywords at 30 entries", () => {
|
||||
const keywords = normalizeKeywords(Array.from({ length: 40 }, (_, index) => `k${index}`).join(","));
|
||||
expect(keywords.split(", ")).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("maps locales to og:locale codes", () => {
|
||||
expect(toOpenGraphLocale("de")).toBe("de_DE");
|
||||
expect(toOpenGraphLocale("en")).toBe("en_US");
|
||||
expect(toOpenGraphLocale("ar")).toBe("ar_AR");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user