98062358be
* build(deps): bump the minor-and-patch group in /backend with 5 updates Bumps the minor-and-patch group in /backend with 5 updates: | Package | From | To | | --- | --- | --- | | [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) | `0.45.1` | `0.45.2` | | [fastify](https://github.com/fastify/fastify) | `5.8.3` | `5.8.4` | | [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.0` | `4.1.2` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.0` | `4.1.2` | Updates `drizzle-orm` from 0.45.1 to 0.45.2 - [Release notes](https://github.com/drizzle-team/drizzle-orm/releases) - [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.45.1...0.45.2) Updates `fastify` from 5.8.3 to 5.8.4 - [Release notes](https://github.com/fastify/fastify/releases) - [Commits](https://github.com/fastify/fastify/compare/v5.8.3...v5.8.4) Updates `@biomejs/biome` from 2.4.8 to 2.4.9 - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.9/packages/@biomejs/biome) Updates `@vitest/coverage-v8` from 4.1.0 to 4.1.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/coverage-v8) Updates `vitest` from 4.1.0 to 4.1.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/vitest) --- updated-dependencies: - dependency-name: drizzle-orm dependency-version: 0.45.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: fastify dependency-version: 5.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: "@biomejs/biome" dependency-version: 2.4.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: vitest dependency-version: 4.1.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] <support@github.com> * test(e2e): wait for login submit button before click * test(e2e): prefer API login in setup with UI fallback * test(e2e): align selectors with current ui testids * chore: rerun ci after merge resolution * chore: trim stale e2e diff from dependency branch --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Daniel Volz <mail@danielvolz.org>
367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { type APIResponse, type Cookie, expect, test as setup } from "@playwright/test";
|
|
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
|
|
|
|
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
|
|
|
|
/**
|
|
* Check if a JWT token is still valid (not expired) without making a
|
|
* network request. Returns `true` when the token has at least 2 minutes
|
|
* of remaining validity.
|
|
*/
|
|
function isTokenValid(token: string): boolean {
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64").toString());
|
|
// Require at least 10 minutes of remaining validity to ensure the token
|
|
// lasts through the entire test run (which can take 7+ minutes)
|
|
return typeof payload.exp === "number" && Date.now() / 1000 < payload.exp - 600;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | null {
|
|
const segments = setCookieHeader
|
|
.split(";")
|
|
.map((segment) => segment.trim())
|
|
.filter(Boolean);
|
|
const [nameValue, ...attributes] = segments;
|
|
if (!nameValue) {
|
|
return null;
|
|
}
|
|
|
|
const separatorIndex = nameValue.indexOf("=");
|
|
if (separatorIndex <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const cookie: Cookie = {
|
|
name: nameValue.slice(0, separatorIndex),
|
|
value: nameValue.slice(separatorIndex + 1),
|
|
url: baseURL,
|
|
path: "/",
|
|
httpOnly: false,
|
|
secure: false,
|
|
sameSite: "Lax",
|
|
};
|
|
|
|
for (const attribute of attributes) {
|
|
const [rawKey, ...rawValueParts] = attribute.split("=");
|
|
const key = rawKey?.toLowerCase();
|
|
const value = rawValueParts.join("=");
|
|
|
|
switch (key) {
|
|
case "expires": {
|
|
const expiresAt = Date.parse(value);
|
|
if (!Number.isNaN(expiresAt)) {
|
|
cookie.expires = Math.floor(expiresAt / 1000);
|
|
}
|
|
break;
|
|
}
|
|
case "httponly":
|
|
cookie.httpOnly = true;
|
|
break;
|
|
case "max-age": {
|
|
const seconds = Number.parseInt(value, 10);
|
|
if (Number.isFinite(seconds)) {
|
|
cookie.expires = Math.floor(Date.now() / 1000) + seconds;
|
|
}
|
|
break;
|
|
}
|
|
case "path":
|
|
cookie.path = value || "/";
|
|
break;
|
|
case "samesite":
|
|
if (/^none$/i.test(value)) {
|
|
cookie.sameSite = "None";
|
|
} else if (/^strict$/i.test(value)) {
|
|
cookie.sameSite = "Strict";
|
|
} else {
|
|
cookie.sameSite = "Lax";
|
|
}
|
|
break;
|
|
case "secure":
|
|
cookie.secure = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return cookie;
|
|
}
|
|
|
|
async function syncResponseCookiesToBrowserContext(
|
|
page: Parameters<Parameters<typeof setup>[0]>[0]["page"],
|
|
baseURL: string,
|
|
response: APIResponse
|
|
): Promise<void> {
|
|
const cookies = response
|
|
.headersArray()
|
|
.filter((header) => header.name.toLowerCase() === "set-cookie")
|
|
.map((header) => toBrowserCookie(header.value, baseURL))
|
|
.filter((cookie): cookie is Cookie => cookie !== null);
|
|
|
|
if (cookies.length > 0) {
|
|
await page.context().addCookies(cookies);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Global setup: ensure a test user exists and persist authenticated state.
|
|
* Runs once before all test projects.
|
|
*
|
|
* Strategy:
|
|
* 1. If a valid auth file exists whose access_token JWT has not expired,
|
|
* reuse it without any network call (saves rate-limit budget).
|
|
* 2. If auth is disabled (no login page), save state immediately.
|
|
* 3. Try to register via API (idempotent — fails silently if user exists).
|
|
* 4. Log in via the UI.
|
|
*/
|
|
setup("authenticate", async ({ page }) => {
|
|
setup.setTimeout(120000);
|
|
await applyVideoSafetyMode(page);
|
|
|
|
// Create .auth directory if it doesn't exist
|
|
const authDir = path.dirname(authFile);
|
|
if (!fs.existsSync(authDir)) {
|
|
fs.mkdirSync(authDir, { recursive: true });
|
|
}
|
|
|
|
// ---- 1. Try to reuse an existing auth file (offline check only) ----
|
|
if (fs.existsSync(authFile)) {
|
|
try {
|
|
const saved = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
|
const accessCookie = saved.cookies?.find((c: { name: string }) => c.name === "access_token");
|
|
if (accessCookie?.value && isTokenValid(accessCookie.value)) {
|
|
// Keep going and verify the session online. A JWT can be time-valid but
|
|
// still rejected by backend token rotation/restart.
|
|
}
|
|
} catch {
|
|
// Invalid file — fall through to regular login
|
|
}
|
|
}
|
|
|
|
// ---- 2. Fast path: already authenticated session ----
|
|
await page.goto("/");
|
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
|
let authEnabled = true;
|
|
let formLoginEnabled = true;
|
|
let oidcEnabled = false;
|
|
let registrationEnabled = true;
|
|
try {
|
|
const stateRes = await page.request.get(`${baseURL}/api/auth/state`);
|
|
if (stateRes.ok()) {
|
|
const state = await stateRes.json();
|
|
authEnabled = state.authEnabled === true;
|
|
formLoginEnabled = state.formLoginEnabled !== false;
|
|
oidcEnabled = state.oidcEnabled === true;
|
|
registrationEnabled = state.registrationEnabled !== false;
|
|
}
|
|
} catch {
|
|
// Fallback: assume auth is enabled and form login is available.
|
|
}
|
|
|
|
// ---- 3. Check if auth is disabled ----
|
|
if (!authEnabled) {
|
|
await page.context().storageState({ path: authFile });
|
|
return;
|
|
}
|
|
|
|
const hasUserMenu = await page
|
|
.locator(".user-menu-btn")
|
|
.isVisible({ timeout: 5000 })
|
|
.catch(() => false);
|
|
if (hasUserMenu) {
|
|
await page.context().storageState({ path: authFile });
|
|
return;
|
|
}
|
|
|
|
const hasAuthenticatedSession = await page.request
|
|
.get(`${baseURL}/api/auth/me`)
|
|
.then((response) => response.ok())
|
|
.catch(() => false);
|
|
if (hasAuthenticatedSession) {
|
|
await page.goto("/");
|
|
await expect(page.locator(".user-menu-btn")).toBeVisible({ timeout: 15000 });
|
|
await page.context().storageState({ path: authFile });
|
|
return;
|
|
}
|
|
|
|
const hasAuthContainer = await page
|
|
.locator(".auth-container")
|
|
.isVisible({ timeout: 5000 })
|
|
.catch(() => false);
|
|
if (!hasAuthContainer) {
|
|
const hasLoginFields = await page
|
|
.locator("#username")
|
|
.isVisible({ timeout: 5000 })
|
|
.catch(() => false);
|
|
if (!hasLoginFields) {
|
|
const becameAuthenticated = await page
|
|
.locator("header.hero")
|
|
.isVisible({ timeout: 5000 })
|
|
.catch(() => false);
|
|
if (becameAuthenticated) {
|
|
await page.context().storageState({ path: authFile });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const loginWithApi = async () => {
|
|
const res = await page.request.post(`${baseURL}/api/auth/login`, {
|
|
data: { username: TEST_USER.username, password: TEST_USER.password, rememberMe: false },
|
|
});
|
|
|
|
if (res.ok()) {
|
|
await syncResponseCookiesToBrowserContext(page, baseURL, res);
|
|
}
|
|
|
|
const bodyText = await res.text().catch(() => "");
|
|
|
|
return {
|
|
bodyText,
|
|
ok: res.ok(),
|
|
status: res.status(),
|
|
};
|
|
};
|
|
|
|
const loginWithApiRetry = async (maxAttempts = 5) => {
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
const result = await loginWithApi();
|
|
if (result.ok) {
|
|
return true;
|
|
}
|
|
|
|
const isRateLimited = result.status === 429 || /too many attempts/i.test(result.bodyText);
|
|
if (!isRateLimited || attempt === maxAttempts) {
|
|
return false;
|
|
}
|
|
|
|
await page.waitForTimeout(1000 * attempt);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const registerWithApi = async () => {
|
|
await page.request
|
|
.post(`${baseURL}/api/auth/register`, {
|
|
data: { username: TEST_USER.username, password: TEST_USER.password },
|
|
})
|
|
.catch(() => {});
|
|
};
|
|
|
|
const ensureAuthenticated = async () => {
|
|
const hasHeader = await page
|
|
.locator("header.hero")
|
|
.isVisible({ timeout: 8000 })
|
|
.catch(() => false);
|
|
if (hasHeader) return true;
|
|
|
|
const meRes = await page.request.get(`${baseURL}/api/auth/me`).catch(() => null);
|
|
return Boolean(meRes?.ok());
|
|
};
|
|
|
|
const hasBrowserAccessCookie = async () => {
|
|
const cookies = await page.context().cookies(baseURL);
|
|
return cookies.some((cookie) => cookie.name === "access_token");
|
|
};
|
|
|
|
// ---- 5. Log in via the appropriate method ----
|
|
if (formLoginEnabled) {
|
|
let loggedIn = await loginWithApiRetry();
|
|
|
|
if (!loggedIn && registrationEnabled) {
|
|
await registerWithApi();
|
|
loggedIn = await loginWithApiRetry();
|
|
}
|
|
|
|
if (loggedIn && (await hasBrowserAccessCookie())) {
|
|
await page.goto("/");
|
|
const isAuthenticated = await ensureAuthenticated();
|
|
if (!isAuthenticated) {
|
|
throw new Error("Authentication succeeded but app shell did not become ready");
|
|
}
|
|
await page.context().storageState({ path: authFile });
|
|
return;
|
|
}
|
|
|
|
// Fallback path for environments where API login flow is unavailable.
|
|
const loginWithForm = async () => {
|
|
const usernameField = page.locator("#username");
|
|
const passwordField = page.locator("#password");
|
|
|
|
// Make sure we're on the login form (not register)
|
|
const isOnRegister = await page
|
|
.locator(".auth-subtitle")
|
|
.filter({ hasText: /Create Account/i })
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
if (isOnRegister) {
|
|
const switchBtn = page.locator("button.auth-link-btn");
|
|
if (await switchBtn.isVisible().catch(() => false)) {
|
|
await switchBtn.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
}
|
|
|
|
await usernameField.clear();
|
|
await usernameField.fill(TEST_USER.username);
|
|
await passwordField.clear();
|
|
await passwordField.fill(TEST_USER.password);
|
|
|
|
// Click the submit button (not the SSO button)
|
|
const submitButton = page.locator('button.auth-submit[type="submit"]');
|
|
await expect(submitButton).toBeEnabled({ timeout: 15000 });
|
|
await submitButton.click();
|
|
};
|
|
|
|
await loginWithForm();
|
|
const hasHeroAfterFirstLogin = await page
|
|
.locator("header.hero")
|
|
.isVisible({ timeout: 5000 })
|
|
.catch(() => false);
|
|
|
|
if (!hasHeroAfterFirstLogin && registrationEnabled) {
|
|
await registerWithApi();
|
|
|
|
await loginWithForm();
|
|
}
|
|
} else if (oidcEnabled) {
|
|
// SSO-only path: click the SSO button and let the OIDC provider handle login.
|
|
// This requires the OIDC provider to be configured with test credentials
|
|
// (e.g. via PLAYWRIGHT_OIDC_USERNAME / PLAYWRIGHT_OIDC_PASSWORD env vars)
|
|
// or to auto-approve the test user.
|
|
await page.locator("button.sso-btn").click();
|
|
|
|
// Wait for OIDC redirect and callback — the provider may show its own login form
|
|
const oidcUsername = process.env.PLAYWRIGHT_OIDC_USERNAME;
|
|
const oidcPassword = process.env.PLAYWRIGHT_OIDC_PASSWORD;
|
|
if (oidcUsername && oidcPassword) {
|
|
// Fill OIDC provider login form (generic selectors — override if needed)
|
|
await page.waitForURL(/.*/, { timeout: 15000 });
|
|
const oidcUserField = page.locator('input[name="username"], input[name="login"], input[type="email"]').first();
|
|
const oidcPassField = page.locator('input[name="password"], input[type="password"]').first();
|
|
if (await oidcUserField.isVisible({ timeout: 10000 }).catch(() => false)) {
|
|
await oidcUserField.fill(oidcUsername);
|
|
await oidcPassField.fill(oidcPassword);
|
|
await page.locator('button[type="submit"]').first().click();
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error("No login method available: form login and OIDC are both disabled");
|
|
}
|
|
|
|
// Wait for successful auth. Prefer app header visibility, but allow verified
|
|
// authenticated API state for environments where shell render is delayed.
|
|
const isAuthenticated = await ensureAuthenticated();
|
|
if (!isAuthenticated) {
|
|
throw new Error("Authentication completed but no authenticated app state was detected");
|
|
}
|
|
|
|
// Persist authenticated state for all test projects
|
|
await page.context().storageState({ path: authFile });
|
|
});
|