feat(dm-dashboard): Wave 2 — Drizzle migration, DmSidebar fix, CORS config
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getCorsHeaders,
|
||||
createCorsResponse,
|
||||
handleCorsPreflight,
|
||||
jsonResponse,
|
||||
} from "../src/lib/cors";
|
||||
|
||||
describe("CORS utility", () => {
|
||||
describe("getCorsHeaders", () => {
|
||||
it("returns headers for allowed origin randify.pro", () => {
|
||||
const headers = getCorsHeaders("https://randify.pro");
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("https://randify.pro");
|
||||
expect(headers["Access-Control-Allow-Methods"]).toBe("GET, POST, OPTIONS");
|
||||
expect(headers["Access-Control-Allow-Headers"]).toBe("Content-Type, Authorization");
|
||||
expect(headers["Access-Control-Allow-Credentials"]).toBe("true");
|
||||
expect(headers["Vary"]).toBe("Origin");
|
||||
});
|
||||
|
||||
it("returns headers for allowed origin dm.randify.pro", () => {
|
||||
const headers = getCorsHeaders("https://dm.randify.pro");
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("https://dm.randify.pro");
|
||||
});
|
||||
|
||||
it("returns headers for allowed origin localhost:4321", () => {
|
||||
const headers = getCorsHeaders("http://localhost:4321");
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("http://localhost:4321");
|
||||
});
|
||||
|
||||
it("returns empty origin for disallowed host", () => {
|
||||
const headers = getCorsHeaders("https://evil.com");
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty origin for null origin", () => {
|
||||
const headers = getCorsHeaders(null);
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty origin for invalid URL", () => {
|
||||
const headers = getCorsHeaders("not-a-url");
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCorsPreflight", () => {
|
||||
it("returns 204 with CORS headers for allowed origin", () => {
|
||||
const response = handleCorsPreflight("https://randify.pro");
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://randify.pro");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, OPTIONS");
|
||||
expect(response.headers.get("Access-Control-Allow-Credentials")).toBe("true");
|
||||
});
|
||||
|
||||
it("returns 204 even for disallowed origin", () => {
|
||||
const response = handleCorsPreflight("https://evil.com");
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCorsResponse", () => {
|
||||
it("wraps a response with CORS headers", () => {
|
||||
const response = createCorsResponse("hello", 200, "https://dm.randify.pro", {
|
||||
"Content-Type": "text/plain",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://dm.randify.pro");
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonResponse", () => {
|
||||
it("serializes data and sets JSON content type", async () => {
|
||||
const response = jsonResponse({ ok: true }, 200, "http://localhost:4321");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/json");
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:4321");
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockRequest(url: string, origin: string, method = "GET") {
|
||||
return {
|
||||
url,
|
||||
method,
|
||||
headers: {
|
||||
get(name: string) {
|
||||
if (name.toLowerCase() === "origin") return origin;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe("DM API health route", () => {
|
||||
it("GET returns 200 with CORS headers", async () => {
|
||||
const { GET } = await import("../src/pages/api/dm/health");
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/health", "https://randify.pro");
|
||||
const response = await GET!({ request, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://randify.pro");
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.dm).toBe(true);
|
||||
});
|
||||
|
||||
it("OPTIONS returns 204 with CORS headers", async () => {
|
||||
const { OPTIONS } = await import("../src/pages/api/dm/health");
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/health", "https://dm.randify.pro", "OPTIONS");
|
||||
const response = await OPTIONS!({ request, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://dm.randify.pro");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("DM Sidebar", () => {
|
||||
test("renders navigation sections when logged out", async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === "mobile-chromium") test.skip();
|
||||
await page.goto("/dm/");
|
||||
const sidebar = page.locator("aside.w-full");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(sidebar.getByText("ИНСТРУМЕНТЫ", { exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("link", { name: "Кубики" })).toBeVisible();
|
||||
await expect(sidebar.getByRole("link", { name: "Инициатива" })).toBeVisible();
|
||||
await expect(sidebar.getByRole("link", { name: "Справочник" })).toBeVisible();
|
||||
await expect(sidebar.getByRole("link", { name: "Заметки" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("does not show user block when logged out", async ({ page }) => {
|
||||
await page.goto("/dm/");
|
||||
await expect(page.locator("[data-testid='sidebar-user-block']")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("user block and tier badge have correct DOM structure", async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === "mobile-chromium") test.skip();
|
||||
await page.goto("/dm/");
|
||||
|
||||
await page.evaluate(() => {
|
||||
const aside = document.querySelector("aside.w-full");
|
||||
if (!aside) return;
|
||||
const block = document.createElement("div");
|
||||
block.setAttribute("data-testid", "sidebar-user-block");
|
||||
block.className = "flex items-center gap-3 mb-6 px-3 py-3 rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)]";
|
||||
block.innerHTML = `
|
||||
<div class="w-8 h-8 rounded-full bg-[var(--accent)]/20 flex items-center justify-center text-[var(--accent)] text-sm font-bold">T</div>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm font-medium text-[var(--text-primary)] truncate">Test User</span>
|
||||
<span data-testid="tier-badge" data-tier="pro" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide bg-[var(--accent)] text-white">PRO</span>
|
||||
</div>
|
||||
`;
|
||||
aside.prepend(block);
|
||||
});
|
||||
|
||||
const userBlock = page.locator("[data-testid='sidebar-user-block']");
|
||||
await expect(userBlock).toBeVisible();
|
||||
await expect(page.getByText("Test User")).toBeVisible();
|
||||
|
||||
const badge = page.locator("[data-testid='tier-badge']");
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toHaveText("PRO");
|
||||
await expect(badge).toHaveAttribute("data-tier", "pro");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user