diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 0000000..c1ea4f4 Binary files /dev/null and b/assets/favicon.ico differ diff --git a/assets/png/1024x1024-white-bg.jpg b/assets/png/1024x1024-white-bg.jpg new file mode 100644 index 0000000..f12f0c1 Binary files /dev/null and b/assets/png/1024x1024-white-bg.jpg differ diff --git a/assets/png/1024x1024-white-bg.png b/assets/png/1024x1024-white-bg.png new file mode 100644 index 0000000..8842309 Binary files /dev/null and b/assets/png/1024x1024-white-bg.png differ diff --git a/assets/png/1024x1024.png b/assets/png/1024x1024.png new file mode 100644 index 0000000..17069a6 Binary files /dev/null and b/assets/png/1024x1024.png differ diff --git a/assets/png/16x16.png b/assets/png/16x16.png new file mode 100644 index 0000000..c0a8926 Binary files /dev/null and b/assets/png/16x16.png differ diff --git a/assets/png/180x180.png b/assets/png/180x180.png new file mode 100644 index 0000000..68f02f9 Binary files /dev/null and b/assets/png/180x180.png differ diff --git a/assets/png/192x192.png b/assets/png/192x192.png new file mode 100644 index 0000000..f8ab2ad Binary files /dev/null and b/assets/png/192x192.png differ diff --git a/assets/png/256x256.png b/assets/png/256x256.png new file mode 100644 index 0000000..3e60b32 Binary files /dev/null and b/assets/png/256x256.png differ diff --git a/assets/png/32x32.png b/assets/png/32x32.png new file mode 100644 index 0000000..6f843a4 Binary files /dev/null and b/assets/png/32x32.png differ diff --git a/assets/png/512x512.png b/assets/png/512x512.png new file mode 100644 index 0000000..9c52165 Binary files /dev/null and b/assets/png/512x512.png differ diff --git a/assets/png/64x64.png b/assets/png/64x64.png new file mode 100644 index 0000000..3a07268 Binary files /dev/null and b/assets/png/64x64.png differ diff --git a/assets/svg/compass.svg b/assets/svg/compass.svg new file mode 100644 index 0000000..2b09ddc --- /dev/null +++ b/assets/svg/compass.svg @@ -0,0 +1,4 @@ + + Compass master + + \ No newline at end of file diff --git a/deploy/compose/compose.dev.yaml b/deploy/compose/compose.dev.yaml index 701a709..e42a2c4 100644 --- a/deploy/compose/compose.dev.yaml +++ b/deploy/compose/compose.dev.yaml @@ -33,6 +33,7 @@ services: environment: DEV_MODE: ${DEV_MODE:-false} SEED_CURRICULUM: ${SEED_CURRICULUM:-false} + CAPTCHA_EXPOSE_ANSWER: ${CAPTCHA_EXPOSE_ANSWER:-false} deploy: resources: limits: @@ -66,6 +67,8 @@ services: # Allow slow, large uploads (admin video ≤ 200MB); default is 60s. - --entrypoints.web.transport.respondingTimeouts.readTimeout=3600 - --entrypoints.websecure.transport.respondingTimeouts.readTimeout=3600 + # Disable HTTP/2 so WebSocket upgrades work from Chromium (RFC 8441 not enabled). + - --entrypoints.websecure.http2.maxConcurrentStreams=0 # Dev: Traefik default certificate (browser warning expected on localhost). - --api.dashboard=true - --api.insecure=true diff --git a/frontend/e2e/.auth-state.json b/frontend/e2e/.auth-state.json new file mode 100644 index 0000000..938a0f3 --- /dev/null +++ b/frontend/e2e/.auth-state.json @@ -0,0 +1 @@ +{"available":true,"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsInJvbGUiOiJhZG1pbiIsInN1YiI6IjEiLCJleHAiOjE3OTI0OTc0MjcsImlhdCI6MTc5MjQ5NjUyN30.0RuGRaPhMeQ36aHrn_x2iAgJPpyH7pfXGr_cOOOeG1c","refreshToken":"e4059f725d0648ae77b0147835296228c5bba0d0bcda638ebb9a65adeed6c5f72e56d0da268de11d34393e9d4ea96acb","user":{"id":1,"email":"admin@example.com","username":"admin","name":"Admin","role":"admin","xp":0,"hearts":5,"heartsUpdatedAt":"2026-10-18T06:49:58.329935Z","streakCurrent":0,"streakLongest":0,"isLocked":false,"isActive":true,"avatarVariant":"beam","avatarPalette":"","phone":"","hasSecurityAnswer":false,"createdAt":"2026-10-18T06:49:58.329935Z","usernameChangesRemaining":3,"isFrozen":false,"isClosed":false}} \ No newline at end of file diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 0000000..175bca7 --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from "@playwright/test"; +import { ADMIN_EMAIL, ADMIN_PASSWORD, BASE, requireAuth, injectAuth, apiLogin } from "./helpers"; + +test.describe("Auth — public endpoints", () => { + test("login page renders", async ({ page }) => { + await page.goto("/login"); + await expect(page).toHaveTitle(/.+/); + const hasForm = await page.locator("form, input[type=password]").count(); + expect(hasForm).toBeGreaterThan(0); + }); + + test("protected routes reject unauthenticated requests", async ({ page }) => { + const routes = ["/api/tree", "/api/leaderboard/weekly", "/api/notifications"]; + for (const route of routes) { + const res = await page.request.get(`${BASE}${route}`); + expect(res.status(), `${route} should be 401`).toBe(401); + } + }); + + test("invalid credentials: bad captcha → 400, bad password → 401", async ({ page }) => { + // Bad captcha answer returns 400 + const r1 = await page.request.post(`${BASE}/api/auth/login`, { + data: { username: "x", password: "x", challengeId: "fake", captchaAnswer: "wrong" }, + }); + expect([400, 429]).toContain(r1.status()); + + // Correct captcha, wrong password → 401 + const auth = requireAuth(); + const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`); + const { challengeId, answer } = await captchaRes.json(); + const r2 = await page.request.post(`${BASE}/api/auth/login`, { + data: { username: ADMIN_EMAIL, password: "definitely-wrong", challengeId, captchaAnswer: answer }, + }); + expect(r2.status()).toBe(401); + }); +}); + +test.describe("Auth — requires working CAPTCHA", () => { + test("admin login via API succeeds and /me returns admin role", async ({ page }) => { + const auth = requireAuth(); + const res = await page.request.get(`${BASE}/api/auth/me`, { + headers: { Authorization: `Bearer ${auth.accessToken}` }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.user.role).toBe("admin"); + }); + + test("logout invalidates refresh token", async ({ page }) => { + requireAuth(); + // Issue a fresh session for this test only (don't use the shared token) + const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`); + const { challengeId, answer } = await captchaRes.json(); + if (!answer) { test.skip(true, "CAPTCHA not solvable"); return; } + + const loginRes = await page.request.post(`${BASE}/api/auth/login`, { + data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD, challengeId, captchaAnswer: answer }, + }); + const { accessToken, refreshToken } = await loginRes.json(); + + const logout = await page.request.post(`${BASE}/api/auth/logout`, { + data: { refreshToken }, + headers: { Authorization: `Bearer ${accessToken}` }, + }); + expect(logout.status()).toBe(200); + + const refresh = await page.request.post(`${BASE}/api/auth/refresh`, { + data: { refreshToken }, + }); + expect(refresh.status()).toBe(401); + }); + + test("cap page loads and stays off /login", async ({ page }) => { + await injectAuth(page); + await page.goto("/cap"); + await page.waitForLoadState("networkidle", { timeout: 15_000 }); + expect(page.url()).not.toContain("/login"); + }); +}); diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts new file mode 100644 index 0000000..fbe21aa --- /dev/null +++ b/frontend/e2e/chat.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "@playwright/test"; +import { BASE, requireAuth, injectAuth } from "./helpers"; + +test.describe("Chat", () => { + test("conversations endpoint returns list", async ({ page }) => { + const auth = requireAuth(); + const res = await page.request.get(`${BASE}/api/chats/conversations`, { + headers: { Authorization: `Bearer ${auth.accessToken}` }, + }); + expect(res.status()).toBe(200); + const data = await res.json(); + expect(data).toHaveProperty("conversations"); + expect(Array.isArray(data.conversations)).toBe(true); + }); + + test("student→student chat is rejected (IDOR guard)", async ({ page }) => { + const auth = requireAuth(); + const adminId = auth.user!.id; + + // Create a student + const u1 = await page.request.post(`${BASE}/api/admin/users`, { + headers: { Authorization: `Bearer ${auth.accessToken}` }, + data: { + name: "ChatTest", email: "chat-idor@e2e.test", username: "chat_idor_e2e", + password: "TestPass123!", role: "student", + securityQuestion: "Q", securityAnswer: "A", + }, + }); + // 201 = created, 409 = already exists from prior run — both are fine + if (u1.status() !== 201 && u1.status() !== 409) { + test.skip(true, `Unexpected status creating test user: ${u1.status()}`); + return; + } + + const studentId = u1.status() === 201 + ? (await u1.json()).user.id + : null; + + if (!studentId) { test.skip(true, "Could not get student ID"); return; } + + // Log in as the student + const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`); + const { challengeId, answer } = await captchaRes.json(); + if (!answer) { test.skip(true, "CAPTCHA not solvable"); return; } + + const loginRes = await page.request.post(`${BASE}/api/auth/login`, { + data: { username: "chat_idor_e2e", password: "TestPass123!", challengeId, captchaAnswer: answer }, + }); + const studentSession = await loginRes.json(); + if (!studentSession.accessToken) { test.skip(true, "Student login failed"); return; } + + // Student tries to read another student's conversations — must fail + const forbidden = await page.request.get(`${BASE}/api/chats/${studentId}/messages`, { + headers: { Authorization: `Bearer ${studentSession.accessToken}` }, + }); + expect([403, 404]).toContain(forbidden.status()); + }); + + test("chat page renders without redirect to /login", async ({ page }) => { + await injectAuth(page); + await page.goto("/unwrap?tab=chats"); + await page.waitForLoadState("networkidle", { timeout: 15_000 }); + expect(page.url()).not.toContain("/login"); + }); +}); diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..8d29295 --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,59 @@ +import { chromium } from "@playwright/test"; +import { ADMIN_EMAIL, ADMIN_PASSWORD, BASE } from "./helpers"; +import * as fs from "fs"; +import * as path from "path"; + +const AUTH_FILE = path.join(__dirname, ".auth-state.json"); + +export default async function globalSetup() { + // If answer is not exposed in captcha, skip auth — tests requiring auth will skip themselves. + const browser = await chromium.launch({ + executablePath: `${process.env.HOME}/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell`, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + try { + const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`); + const captchaData = await captchaRes.json(); + + if (!captchaData.answer) { + console.warn("\n⚠ CAPTCHA_EXPOSE_ANSWER not set — restart the API container to enable auth tests.\n Run: ./scripts/compose-dev.sh restart api\n"); + fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false })); + await browser.close(); + return; + } + + const loginRes = await page.request.post(`${BASE}/api/auth/login`, { + data: { + username: ADMIN_EMAIL, + password: ADMIN_PASSWORD, + challengeId: captchaData.challengeId, + captchaAnswer: captchaData.answer, + rememberMe: true, + }, + }); + const body = await loginRes.json(); + + if (!body.accessToken) { + console.warn("\n⚠ Admin login failed in global setup:", JSON.stringify(body)); + fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false })); + await browser.close(); + return; + } + + fs.writeFileSync(AUTH_FILE, JSON.stringify({ + available: true, + accessToken: body.accessToken, + refreshToken: body.refreshToken, + user: body.user, + })); + console.log("\n✓ Auth state cached for test suite\n"); + } catch (e) { + fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false })); + console.warn("\n⚠ Global setup failed:", e); + } + + await browser.close(); +} diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts new file mode 100644 index 0000000..f04f8bb --- /dev/null +++ b/frontend/e2e/helpers.ts @@ -0,0 +1,83 @@ +import { type Page, test } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; + +export const ADMIN_EMAIL = "admin"; +export const ADMIN_PASSWORD = "wZW64CUbXbv9679dmR1FIbz3"; +export const BASE = "https://localhost"; + +const AUTH_FILE = path.join(__dirname, ".auth-state.json"); + +interface AuthState { + available: boolean; + accessToken?: string; + refreshToken?: string; + user?: { id: number; role: string; email: string }; +} + +export function loadAuth(): AuthState { + try { + return JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")) as AuthState; + } catch { + return { available: false }; + } +} + +/** Skip this test if auth is not available (CAPTCHA not exposed). */ +export function requireAuth(): AuthState { + const auth = loadAuth(); + if (!auth.available) { + test.skip(true, "Auth not available — restart API with CAPTCHA_EXPOSE_ANSWER=true"); + } + return auth; +} + +/** Inject cached auth cookies into the page context. */ +export async function injectAuth(page: Page): Promise { + const auth = requireAuth(); + // Seed Zustand persist store so the app hydrates with a valid user (skips redirect) + await page.addInitScript(({ user }) => { + try { + localStorage.setItem( + "pargar.auth", + JSON.stringify({ state: { user }, version: 0 }), + ); + } catch {} + }, { user: auth.user }); + await page.context().addCookies([ + { name: "pargar_access", value: auth.accessToken!, domain: "localhost", path: "/", secure: true, sameSite: "Lax" }, + { name: "pargar_refresh", value: auth.refreshToken!, domain: "localhost", path: "/", secure: true, httpOnly: true, sameSite: "Lax" }, + ]); + return auth; +} + +export async function loginAs(page: Page, identity: string, password: string) { + const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`); + const captchaData = await captchaRes.json(); + const answer: string = captchaData.answer ?? ""; + const challengeId: string = captchaData.challengeId ?? ""; + + const loginRes = await page.request.post(`${BASE}/api/auth/login`, { + data: { username: identity, password, challengeId, captchaAnswer: answer, rememberMe: false }, + }); + const loginBody = await loginRes.json(); + + if (!loginBody.accessToken) { + throw new Error(`Login failed for ${identity}: ${JSON.stringify(loginBody)}`); + } + + await page.context().addCookies([ + { name: "access_token", value: loginBody.accessToken, domain: "localhost", path: "/", secure: true, sameSite: "Lax" }, + { name: "refresh_token", value: loginBody.refreshToken, domain: "localhost", path: "/", secure: true, httpOnly: true, sameSite: "Lax" }, + ]); + return loginBody; +} + +export async function apiLogin(page: Page, identity: string, password: string) { + const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`); + const { challengeId, answer } = await captchaRes.json(); + const res = await page.request.post(`${BASE}/api/auth/login`, { + data: { username: identity, password, challengeId, captchaAnswer: answer, rememberMe: false }, + }); + return res.json(); +} diff --git a/frontend/e2e/leaderboard.spec.ts b/frontend/e2e/leaderboard.spec.ts new file mode 100644 index 0000000..ffd882f --- /dev/null +++ b/frontend/e2e/leaderboard.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "@playwright/test"; +import { BASE, requireAuth, injectAuth } from "./helpers"; + +test.describe("Leaderboard", () => { + test("weekly leaderboard returns valid shape", async ({ page }) => { + const auth = requireAuth(); + const res = await page.request.get(`${BASE}/api/leaderboard/weekly`, { + headers: { Authorization: `Bearer ${auth.accessToken}` }, + }); + expect(res.status()).toBe(200); + const data = await res.json(); + expect(data).toHaveProperty("week"); + expect(data).toHaveProperty("entries"); + expect(data).toHaveProperty("totalParticipants"); + expect(data).toHaveProperty("me"); + expect(Array.isArray(data.entries)).toBe(true); + }); + + test("leaderboard entries have tier field", async ({ page }) => { + const auth = requireAuth(); + const res = await page.request.get(`${BASE}/api/leaderboard/weekly`, { + headers: { Authorization: `Bearer ${auth.accessToken}` }, + }); + const data = await res.json(); + for (const entry of data.entries) { + expect(["champion", "gold", "silver", "bronze"]).toContain(entry.tier); + expect(entry.rank).toBeGreaterThanOrEqual(1); + } + }); + + test("leaderboard page renders without redirect to /login", async ({ page }) => { + await injectAuth(page); + await page.goto("/leaderboard"); + await page.waitForLoadState("networkidle", { timeout: 15_000 }); + expect(page.url()).not.toContain("/login"); + }); +}); diff --git a/frontend/e2e/responsive.spec.ts b/frontend/e2e/responsive.spec.ts new file mode 100644 index 0000000..c9859bf --- /dev/null +++ b/frontend/e2e/responsive.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from "@playwright/test"; +import { requireAuth, injectAuth } from "./helpers"; + +const VIEWPORTS = [ + { name: "mobile-sm (375px)", width: 375, height: 812 }, + { name: "mobile-lg (430px)", width: 430, height: 932 }, + { name: "tablet (768px)", width: 768, height: 1024 }, + { name: "desktop (1280px)", width: 1280, height: 800 }, + { name: "desktop-xl (1440px)", width: 1440, height: 900 }, +]; + +const ROUTES = ["/cap", "/leaderboard", "/community", "/settings", "/notifications"]; + +for (const vp of VIEWPORTS) { + test(`no horizontal overflow — ${vp.name}`, async ({ page }) => { + requireAuth(); + await page.setViewportSize({ width: vp.width, height: vp.height }); + await injectAuth(page); + + for (const route of ROUTES) { + await page.goto(route); + await page.waitForLoadState("networkidle", { timeout: 15_000 }); + + const overflow = await page.evaluate(() => { + return document.documentElement.scrollWidth > document.documentElement.clientWidth; + }); + expect(overflow, `Horizontal overflow at ${route} / ${vp.name}`).toBe(false); + } + }); + + test(`no interactive elements clipped off-screen — ${vp.name}`, async ({ page }) => { + requireAuth(); + await page.setViewportSize({ width: vp.width, height: vp.height }); + await injectAuth(page); + + await page.goto("/cap"); + await page.waitForLoadState("networkidle", { timeout: 15_000 }); + + const clipped = await page.evaluate((vpWidth) => { + return Array.from(document.querySelectorAll("button, a, input")) + .filter((el) => { + const r = el.getBoundingClientRect(); + return r.right < 0 || r.left > vpWidth + 50; + }).length; + }, vp.width); + + expect(clipped, `${clipped} elements clipped at ${vp.name}`).toBe(0); + }); +} diff --git a/frontend/e2e/video.spec.ts b/frontend/e2e/video.spec.ts new file mode 100644 index 0000000..e562a9a --- /dev/null +++ b/frontend/e2e/video.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "@playwright/test"; +import { BASE, requireAuth, injectAuth } from "./helpers"; + +test.describe("Video player & media signing", () => { + test("lesson endpoint returns a signed videoUrl", async ({ page }) => { + const auth = requireAuth(); + const treeRes = await page.request.get(`${BASE}/api/tree`, { + headers: { Authorization: `Bearer ${auth.accessToken}` }, + }); + expect(treeRes.status()).toBe(200); + const tree = await treeRes.json(); + const firstLesson = tree.chapters?.[0]?.lessons?.[0]; + if (!firstLesson) { test.skip(true, "No lessons in tree"); return; } + + const lessonRes = await page.request.get(`${BASE}/api/lessons/${firstLesson.id}`, { + headers: { Authorization: `Bearer ${auth.accessToken}` }, + }); + expect(lessonRes.status()).toBe(200); + const lesson = await lessonRes.json(); + + expect(lesson.videoUrl).toBeTruthy(); + expect(lesson.videoUrl).toMatch(/^https?:\/\//); + + // Must be signed (has query params) or served via /media/ proxy + const url = new URL(lesson.videoUrl); + const isSigned = url.searchParams.size > 0 || lesson.videoUrl.includes("/media/"); + expect(isSigned, `videoUrl not signed: ${lesson.videoUrl}`).toBe(true); + }); + + test("player page renders without JS errors for first lesson", async ({ page }) => { + const auth = requireAuth(); + const treeRes = await page.request.get(`${BASE}/api/tree`, { + headers: { Authorization: `Bearer ${auth.accessToken}` }, + }); + const tree = await treeRes.json(); + const firstLesson = tree.chapters?.[0]?.lessons?.[0]; + if (!firstLesson) { test.skip(true, "No lessons in tree"); return; } + + const jsErrors: string[] = []; + page.on("pageerror", (err) => jsErrors.push(err.message)); + + await injectAuth(page); + await page.goto(`/player/${firstLesson.id}`); + await page.waitForLoadState("networkidle", { timeout: 20_000 }); + + // Filter known expected media errors (video load failures in headless are expected) + const realErrors = jsErrors.filter( + (e) => !e.includes("video") && !e.includes("media") && !e.includes("hls") && !e.includes("AbortError") + ); + expect(realErrors, `JS errors on player page: ${realErrors.join(", ")}`).toHaveLength(0); + }); + + test("admin videos panel fires no media requests on grid load", async ({ page }) => { + requireAuth(); + const videoRequests: string[] = []; + page.on("request", (req) => { + if (req.resourceType() === "media" || /\.(mp4|m3u8|ts)(\?|$)/.test(req.url())) { + videoRequests.push(req.url()); + } + }); + + await injectAuth(page); + await page.goto("/admin"); + await page.waitForLoadState("networkidle", { timeout: 15_000 }); + + // Click Videos tab if present + const videosTab = page.getByRole("button", { name: /ویدیو/i }); + if (await videosTab.count() > 0) { + await videosTab.first().click(); + await page.waitForLoadState("networkidle", { timeout: 10_000 }); + } + + expect(videoRequests, `Unexpected media requests on grid load: ${videoRequests.join(", ")}`).toHaveLength(0); + }); +}); diff --git a/frontend/e2e/websocket.spec.ts b/frontend/e2e/websocket.spec.ts new file mode 100644 index 0000000..17dd00b --- /dev/null +++ b/frontend/e2e/websocket.spec.ts @@ -0,0 +1,56 @@ +import * as tls from "tls"; +import { test, expect } from "@playwright/test"; +import { BASE, requireAuth } from "./helpers"; + +test.describe("WebSocket", () => { + // Test WS via raw TLS to avoid browser HTTP/2 and Node.js http module quirks. + test("WS handshake with valid token succeeds", async () => { + const auth = requireAuth(); + + const statusCode = await new Promise((resolve) => { + const socket = tls.connect( + { host: "localhost", port: 443, rejectUnauthorized: false }, + () => { + const handshake = [ + "GET /api/ws HTTP/1.1", + "Host: localhost", + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version: 13", + `Authorization: Bearer ${auth.accessToken!}`, + "", + "", + ].join("\r\n"); + socket.write(handshake); + }, + ); + let buf = ""; + socket.on("data", (chunk) => { + buf += chunk.toString(); + const firstLine = buf.split("\r\n")[0] ?? ""; + const m = firstLine.match(/HTTP\/1\.\d\s+(\d+)/); + if (m) { + socket.destroy(); + resolve(parseInt(m[1], 10)); + } + }); + socket.on("error", () => resolve(0)); + setTimeout(() => { socket.destroy(); resolve(0); }, 8000); + }); + + expect(statusCode, `WS upgrade returned ${statusCode}`).toBe(101); + }); + + test("WS handshake without token is rejected", async ({ page }) => { + const result = await page.evaluate((wsUrl) => { + return new Promise<{ code: number }>((resolve) => { + const ws = new WebSocket(wsUrl); + ws.onclose = (e) => resolve({ code: e.code }); + setTimeout(() => resolve({ code: -1 }), 6000); + }); + }, BASE.replace("https", "wss") + "/api/ws"); + + expect(result.code).not.toBe(1000); + }); +}); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..6fa13d4 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + retries: 0, + workers: 1, + globalSetup: "./e2e/global-setup.ts", + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], + use: { + baseURL: "https://localhost", + ignoreHTTPSErrors: true, + screenshot: "only-on-failure", + video: "off", + trace: "retain-on-failure", + locale: "fa-IR", + }, + projects: [ + { + name: "chromium", + use: { + browserName: "chromium", + viewport: { width: 1280, height: 800 }, + launchOptions: { + executablePath: `${process.env.HOME}/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell`, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-http2"], + }, + }, + }, + ], +});