Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api";
|
||||
|
||||
test.describe("§15.7 scenarios 5 & 9: Admin users", () => {
|
||||
test("blocked user access token rejected immediately after block", async ({ request }) => {
|
||||
const email = uniqueEmail("e2e-block-jwt");
|
||||
const session = await registerVerifyLogin(request, email);
|
||||
const admin = await adminLogin(request);
|
||||
const adminToken = (await admin.json()).access_token;
|
||||
|
||||
const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
data: { status: "blocked" }
|
||||
});
|
||||
expect(patch.ok()).toBeTruthy();
|
||||
|
||||
const me = await request.get(`${API_URL}/api/v1/users/me`, {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` }
|
||||
});
|
||||
expect(me.status()).toBe(401);
|
||||
expect((await me.json()).detail).toBe("TOKEN_REVOKED");
|
||||
});
|
||||
|
||||
test("admin blocks user → blocked user cannot login", async ({ page, request }) => {
|
||||
const email = uniqueEmail("e2e-block");
|
||||
const session = await registerVerifyLogin(request, email);
|
||||
const admin = await adminLogin(request);
|
||||
const adminToken = (await admin.json()).access_token;
|
||||
|
||||
const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
data: { status: "blocked" }
|
||||
});
|
||||
expect(patch.ok()).toBeTruthy();
|
||||
|
||||
await loginViaUi(page, email, "Valid1234");
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
test("admin can open platform users page", async ({ page }) => {
|
||||
await loginViaUi(page, "admin@compton.example", "Admin1234");
|
||||
await expect(page).toHaveURL(/\/recipes$/);
|
||||
await page.goto("/platform");
|
||||
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("admin session survives reload on /platform", async ({ page }) => {
|
||||
await loginViaUi(page, "admin@compton.example", "Admin1234");
|
||||
await page.goto("/platform");
|
||||
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
|
||||
await page.goto("/platform");
|
||||
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("non-admin cannot open platform page", async ({ page, request }) => {
|
||||
const email = uniqueEmail("e2e-nonadmin");
|
||||
await registerVerifyLogin(request, email);
|
||||
await loginViaUi(page, email, "Valid1234");
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await page.goto("/platform");
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
});
|
||||
|
||||
test("admin cannot block self", async ({ request }) => {
|
||||
const admin = await adminLogin(request);
|
||||
const body = await admin.json();
|
||||
const response = await request.patch(`${API_URL}/api/v1/admin/users/${body.user.id}`, {
|
||||
headers: { Authorization: `Bearer ${body.access_token}` },
|
||||
data: { status: "blocked" }
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
expect((await response.json()).detail).toBe("SELF_BLOCK_FORBIDDEN");
|
||||
});
|
||||
|
||||
test("admin cannot demote self", async ({ request }) => {
|
||||
const admin = await adminLogin(request);
|
||||
const body = await admin.json();
|
||||
const response = await request.patch(`${API_URL}/api/v1/admin/users/${body.user.id}`, {
|
||||
headers: { Authorization: `Bearer ${body.access_token}` },
|
||||
data: { role: "user" }
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
expect((await response.json()).detail).toBe("SELF_DEMOTION_FORBIDDEN");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("login page renders", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("heading", { name: "Вход" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { API_URL, fetchLatestToken, registerVerifyLogin, uniqueEmail } from "../helpers/api";
|
||||
|
||||
test.describe("§15.7 scenario 3: Password reset", () => {
|
||||
test("forgot password → reset → login with new password", async ({ request }) => {
|
||||
const email = uniqueEmail("e2e-reset");
|
||||
const oldPassword = "Valid1234";
|
||||
const newPassword = "ResetValid1";
|
||||
|
||||
await registerVerifyLogin(request, email, oldPassword);
|
||||
|
||||
const forgot = await request.post(`${API_URL}/api/v1/auth/forgot-password`, {
|
||||
data: { email }
|
||||
});
|
||||
expect(forgot.ok()).toBeTruthy();
|
||||
|
||||
const token = await fetchLatestToken(request, email, "reset_password");
|
||||
const reset = await request.post(`${API_URL}/api/v1/auth/reset-password`, {
|
||||
data: { token, new_password: newPassword }
|
||||
});
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
|
||||
const oldLogin = await request.post(`${API_URL}/api/v1/auth/login`, {
|
||||
data: { email, password: oldPassword }
|
||||
});
|
||||
expect(oldLogin.status()).toBe(401);
|
||||
|
||||
const newLogin = await request.post(`${API_URL}/api/v1/auth/login`, {
|
||||
data: { email, password: newPassword }
|
||||
});
|
||||
expect(newLogin.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api";
|
||||
|
||||
function extractRefreshCookie(headers: Record<string, string>): string {
|
||||
const setCookie = headers["set-cookie"] ?? headers["Set-Cookie"] ?? "";
|
||||
const match = setCookie.match(/refresh_token=([^;]+)/);
|
||||
if (!match) {
|
||||
throw new Error("refresh_token cookie missing");
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test.describe("§15.7 scenario 7: Refresh rotation", () => {
|
||||
test("old refresh token rejected after rotation; reuse revokes family", async ({ request }) => {
|
||||
const email = uniqueEmail("e2e-refresh");
|
||||
await registerVerifyLogin(request, email);
|
||||
|
||||
const login = await request.post(`${API_URL}/api/v1/auth/login`, { data: { email, password: "Valid1234" } });
|
||||
const oldRefresh = extractRefreshCookie(login.headers());
|
||||
|
||||
const rotated = await request.post(`${API_URL}/api/v1/auth/refresh`, {
|
||||
headers: { Cookie: `refresh_token=${oldRefresh}` }
|
||||
});
|
||||
expect(rotated.ok()).toBeTruthy();
|
||||
const newRefresh = extractRefreshCookie(rotated.headers());
|
||||
|
||||
const oldReuse = await request.post(`${API_URL}/api/v1/auth/refresh`, {
|
||||
headers: { Cookie: `refresh_token=${oldRefresh}` }
|
||||
});
|
||||
expect(oldReuse.status()).toBe(401);
|
||||
|
||||
const familyReuse = await request.post(`${API_URL}/api/v1/auth/refresh`, {
|
||||
headers: { Cookie: `refresh_token=${newRefresh}` }
|
||||
});
|
||||
expect(familyReuse.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
loginViaUi,
|
||||
registerUser,
|
||||
uniqueEmail,
|
||||
verifyEmail
|
||||
} from "../helpers/api";
|
||||
|
||||
test.describe("§15.7 scenario 2: Auth full journey", () => {
|
||||
test("register → verify → login → profile edit → change password → logout", async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
const email = uniqueEmail("e2e-flow");
|
||||
const password = "Valid1234";
|
||||
const newPassword = "NewValid1";
|
||||
|
||||
await registerUser(request, email, password);
|
||||
await verifyEmail(request, email);
|
||||
|
||||
await loginViaUi(page, email, password);
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await expect(page.getByText(email)).toBeVisible();
|
||||
|
||||
await page.getByLabel("Display name").fill("E2E User");
|
||||
await page.getByRole("button", { name: "Save name" }).click();
|
||||
await expect(page.getByText("Profile updated")).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Current password").fill(password);
|
||||
await page.getByPlaceholder("New password").fill(newPassword);
|
||||
await page.getByRole("button", { name: "Change password" }).click();
|
||||
await expect(page.getByText("Password changed")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Logout" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
await loginViaUi(page, email, newPassword);
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { API_URL, adminLogin } from "../helpers/api";
|
||||
|
||||
test.describe("§15.7 scenario 4: Admin content publish", () => {
|
||||
test("admin creates content → publish → visible on /pages/:slug", async ({ page, request }) => {
|
||||
const admin = await adminLogin(request);
|
||||
const headers = { Authorization: `Bearer ${(await admin.json()).access_token}` };
|
||||
const slug = `e2e-page-${Date.now()}`;
|
||||
|
||||
const created = await request.post(`${API_URL}/api/v1/content/pages`, {
|
||||
headers,
|
||||
data: {
|
||||
slug,
|
||||
title: "E2E Published Page",
|
||||
body: "<p>Published by admin</p>",
|
||||
status: "published"
|
||||
}
|
||||
});
|
||||
expect(created.ok()).toBeTruthy();
|
||||
|
||||
await page.goto(`/pages/${slug}`);
|
||||
await expect(page.getByRole("heading", { name: "E2E Published Page" })).toBeVisible();
|
||||
await expect(page.getByText("Published by admin")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { APIRequestContext } from "@playwright/test";
|
||||
|
||||
export const API_URL = process.env.E2E_API_URL ?? "http://127.0.0.1:8001";
|
||||
|
||||
export function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
export async function registerUser(request: APIRequestContext, email: string, password = "Valid1234") {
|
||||
const response = await request.post(`${API_URL}/api/v1/auth/register`, {
|
||||
data: { email, password }
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function fetchLatestToken(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
template: "verify_email" | "reset_password"
|
||||
): Promise<string> {
|
||||
const response = await request.get(`${API_URL}/api/v1/test/emails/latest-token`, {
|
||||
params: { to: email, template }
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Token not found for ${email} (${template})`);
|
||||
}
|
||||
const body = await response.json();
|
||||
return body.token as string;
|
||||
}
|
||||
|
||||
export async function verifyEmail(request: APIRequestContext, email: string) {
|
||||
const token = await fetchLatestToken(request, email, "verify_email");
|
||||
return request.post(`${API_URL}/api/v1/auth/verify-email`, { data: { token } });
|
||||
}
|
||||
|
||||
export async function loginApi(request: APIRequestContext, email: string, password = "Valid1234") {
|
||||
return request.post(`${API_URL}/api/v1/auth/login`, { data: { email, password } });
|
||||
}
|
||||
|
||||
export async function adminLogin(request: APIRequestContext) {
|
||||
return loginApi(request, "admin@compton.example", "Admin1234");
|
||||
}
|
||||
|
||||
export async function registerVerifyLogin(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password = "Valid1234"
|
||||
) {
|
||||
await registerUser(request, email, password);
|
||||
await verifyEmail(request, email);
|
||||
const login = await loginApi(request, email, password);
|
||||
const body = await login.json();
|
||||
return {
|
||||
accessToken: body.access_token as string,
|
||||
user: body.user as { id: string; email: string; role: string; status: string }
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginViaUi(page: import("@playwright/test").Page, email: string, password: string) {
|
||||
await page.goto("/login");
|
||||
await page.getByPlaceholder("Email").fill(email);
|
||||
await page.getByPlaceholder("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("§15.7 scenario 1: Landing", () => {
|
||||
test("hero and marquee are visible", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: /Технологии будущего на вашей ферме/i })).toBeVisible();
|
||||
await expect(page.locator("#integrations .marquee-track")).toBeVisible();
|
||||
});
|
||||
|
||||
test("respects prefers-reduced-motion", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: /Технологии будущего на вашей ферме/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api";
|
||||
|
||||
const tinyPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64"
|
||||
);
|
||||
|
||||
test.describe("§15.7 scenario 10: Avatar validation", () => {
|
||||
test("rejects svg, oversize and accepts valid png", async ({ request }) => {
|
||||
const session = await registerVerifyLogin(request, uniqueEmail("e2e-avatar"));
|
||||
const headers = { Authorization: `Bearer ${session.accessToken}` };
|
||||
|
||||
const svg = await request.post(`${API_URL}/api/v1/users/me/avatar`, {
|
||||
headers,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "avatar.svg",
|
||||
mimeType: "image/svg+xml",
|
||||
buffer: Buffer.from("<svg xmlns='http://www.w3.org/2000/svg'></svg>")
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(svg.status()).toBe(400);
|
||||
|
||||
const oversize = await request.post(`${API_URL}/api/v1/users/me/avatar`, {
|
||||
headers,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "big.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.concat([tinyPng, Buffer.alloc(2 * 1024 * 1024 + 1)])
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(oversize.status()).toBe(400);
|
||||
expect((await oversize.json()).detail).toBe("FILE_TOO_LARGE");
|
||||
|
||||
const valid = await request.post(`${API_URL}/api/v1/users/me/avatar`, {
|
||||
headers,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "avatar.png",
|
||||
mimeType: "image/png",
|
||||
buffer: tinyPng
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(valid.ok()).toBeTruthy();
|
||||
expect((await valid.json()).profile.avatar_url).toContain("/api/v1/media/files/avatars/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { registerUser, uniqueEmail } from "../helpers/api";
|
||||
|
||||
test.describe("§15.7 scenario 6: Pending user", () => {
|
||||
test("pending user cannot access /profile", async ({ page, request }) => {
|
||||
const email = uniqueEmail("e2e-pending");
|
||||
await registerUser(request, email, "Valid1234");
|
||||
|
||||
await page.goto("/profile");
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByPlaceholder("Email").fill(email);
|
||||
await page.getByPlaceholder("Password").fill("Valid1234");
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api";
|
||||
|
||||
test.describe("§15.7 scenario 8: IDOR", () => {
|
||||
test("user A cannot patch user B via admin route", async ({ request }) => {
|
||||
const userA = await registerVerifyLogin(request, uniqueEmail("e2e-a"));
|
||||
const userB = await registerVerifyLogin(request, uniqueEmail("e2e-b"));
|
||||
|
||||
const forbidden = await request.patch(`${API_URL}/api/v1/admin/users/${userB.user.id}`, {
|
||||
headers: { Authorization: `Bearer ${userA.accessToken}` },
|
||||
data: { status: "blocked" }
|
||||
});
|
||||
expect(forbidden.status()).toBe(403);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user