diff --git a/tests/e2e/ui/fixtures/seed.sql b/tests/e2e/ui/fixtures/seed.sql index 00ea668ed8f..48060536596 100644 --- a/tests/e2e/ui/fixtures/seed.sql +++ b/tests/e2e/ui/fixtures/seed.sql @@ -2,6 +2,8 @@ -- Idempotent: deletes all e2e-* rows then re-inserts deterministic data. -- 1. Clean up in dependency order +DELETE FROM "LiteLLM_InvitationLink" +WHERE "user_id" LIKE 'e2e-%' OR "created_by" LIKE 'e2e-%' OR "updated_by" LIKE 'e2e-%'; DELETE FROM "LiteLLM_TeamMembership" WHERE "user_id" LIKE 'e2e-%'; DELETE FROM "LiteLLM_VerificationToken" WHERE token LIKE 'e2e-%'; DELETE FROM "LiteLLM_TeamTable" WHERE "team_id" LIKE 'e2e-%'; diff --git a/tests/e2e/ui/globalSetup.ts b/tests/e2e/ui/globalSetup.ts index e7d1655380d..9447c93a72e 100644 --- a/tests/e2e/ui/globalSetup.ts +++ b/tests/e2e/ui/globalSetup.ts @@ -1,6 +1,7 @@ import { chromium, expect, request } from "@playwright/test"; import { users, Role, STORAGE_PATHS } from "./fixtures/users"; import { ARTIFACT_DIR, UI_BASE_URL } from "./constants"; +import { expectUnrestrictedDashboard, setInvitedUserPassword } from "./helpers/userOnboarding"; import * as fs from "fs"; import * as path from "path"; @@ -30,32 +31,37 @@ async function globalSetup() { throw new Error(`Enabling enable_projects_ui failed (${settingsRes.status()}): ${await settingsRes.text()}`); } - for (const { email, password, seedApiRole } of Object.values(users)) { - if (!seedApiRole) { - continue; - } - const createRes = await api.post(`${UI_BASE_URL}${rootPath}/user/new`, { - headers: { Authorization: `Bearer ${masterKey}` }, - data: { user_email: email, user_role: seedApiRole, auto_create_key: false }, - }); - if (!createRes.ok() && createRes.status() !== 409) { - throw new Error(`Seeding user ${email} failed (${createRes.status()}): ${await createRes.text()}`); - } - const passwordRes = await api.post(`${UI_BASE_URL}${rootPath}/user/update`, { - headers: { Authorization: `Bearer ${masterKey}` }, - data: { user_email: email, password }, - }); - if (!passwordRes.ok()) { - throw new Error(`Setting password for ${email} failed (${passwordRes.status()}): ${await passwordRes.text()}`); - } - } - await api.dispose(); - - for (const role of Object.values(Role)) { - const { email, password } = users[role]; + const roles = [Role.ProxyAdmin, ...Object.values(Role).filter((role) => role !== Role.ProxyAdmin)]; + for (const role of roles) { + const { email, password, seedApiRole } = users[role]; const storagePath = STORAGE_PATHS[role]; const page = await browser.newPage(); try { + if (seedApiRole) { + const createRes = await api.post(`${UI_BASE_URL}${rootPath}/user/new`, { + headers: { Authorization: `Bearer ${masterKey}` }, + data: { user_email: email, user_role: seedApiRole, auto_create_key: false }, + }); + if (!createRes.ok() && createRes.status() !== 409) { + throw new Error(`Seeding user ${email} failed (${createRes.status()}): ${await createRes.text()}`); + } + const userId = createRes.ok() + ? (await createRes.json()).user_id + : await (async () => { + const existing = await api.get(`${UI_BASE_URL}${rootPath}/user/list`, { + headers: { Authorization: `Bearer ${masterKey}` }, + params: { user_email: email }, + }); + expect(existing.ok(), `Find seeded user ${email}: HTTP ${existing.status()}`).toBe(true); + const matches = (await existing.json()).users.filter( + (user: { user_email: string }) => user.user_email === email, + ); + expect(matches, `Exactly one seeded user for ${email}`).toHaveLength(1); + return matches[0].user_id; + })(); + expect(typeof userId, `User ID for ${email}`).toBe("string"); + await setInvitedUserPassword(api, userId, password); + } await page.goto(`${UI_BASE_URL}${rootPath}/ui/login`); await page.getByPlaceholder("Enter your username").fill(email); await page.getByPlaceholder("Enter your password").fill(password); @@ -63,7 +69,7 @@ async function globalSetup() { await page.waitForURL((url) => url.pathname.startsWith(`${rootPath}/ui`) && !url.pathname.includes("/login"), { timeout: 30_000, }); - await expect(page.locator("a", { hasText: "Virtual Keys" })).toBeVisible({ timeout: 30_000 }); + await expectUnrestrictedDashboard(page); // Dismiss feedback popup if present const dismiss = page.getByText("Don't ask me again"); if (await dismiss.isVisible({ timeout: 1_500 }).catch(() => false)) { @@ -100,6 +106,7 @@ async function globalSetup() { } } + await api.dispose(); await browser.close(); } diff --git a/tests/e2e/ui/helpers/userOnboarding.ts b/tests/e2e/ui/helpers/userOnboarding.ts new file mode 100644 index 00000000000..a1ea6e5e82b --- /dev/null +++ b/tests/e2e/ui/helpers/userOnboarding.ts @@ -0,0 +1,59 @@ +import { expect, type APIRequestContext, type Page } from "@playwright/test"; +import { UI_BASE_URL } from "../constants"; +import { masterKey, rootPath } from "./traffic"; + +const endpoint = (route: string): string => `${UI_BASE_URL}${rootPath()}${route}`; + +export async function setInvitedUserPassword( + request: APIRequestContext, + userId: string, + password: string, +): Promise { + const invitation = await request.post(endpoint("/invitation/new"), { + headers: { Authorization: `Bearer ${masterKey()}` }, + data: { user_id: userId }, + }); + expect(invitation.ok(), `Create invitation for ${userId}: HTTP ${invitation.status()}`).toBe(true); + const { id } = await invitation.json(); + expect(typeof id, "invitation ID").toBe("string"); + + const onboarding = await request.get(endpoint("/onboarding/get_token"), { + params: { invite_link: id }, + }); + expect(onboarding.ok(), `Get onboarding session for ${userId}: HTTP ${onboarding.status()}`).toBe(true); + const { token } = await onboarding.json(); + const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8")); + expect(typeof payload.key, "onboarding credential").toBe("string"); + const claimed = await request.post(endpoint("/onboarding/claim_token"), { + headers: { Authorization: `Bearer ${payload.key}` }, + data: { invitation_link: id, user_id: userId, password }, + }); + expect(claimed.ok(), `Claim invitation for ${userId}: HTTP ${claimed.status()}`).toBe(true); +} + +export async function readDashboardSession(page: Page): Promise<{ + key: string; + user_id: string; + password_reset_required?: boolean; +}> { + await expect.poll(async () => (await page.context().cookies()).some((cookie) => cookie.name === "token")).toBe(true); + const cookie = (await page.context().cookies()).find((candidate) => candidate.name === "token")!; + return JSON.parse(Buffer.from(cookie.value.split(".")[1], "base64url").toString("utf-8")); +} + +export async function expectUnrestrictedDashboard(page: Page): Promise { + const virtualKeys = page.getByRole("complementary").getByRole("link", { name: "Virtual Keys", exact: true }); + await expect(virtualKeys).toBeVisible({ timeout: 30_000 }); + const session = await readDashboardSession(page); + expect(session.password_reset_required === true, "login must not require a password reset").toBe(false); + await virtualKeys.click(); + await expect(page.getByRole("main").getByRole("heading", { name: "Virtual Keys", exact: true })).toBeVisible({ + timeout: 30_000, + }); + const info = await page.request.get(endpoint("/user/info"), { + headers: { Authorization: `Bearer ${session.key}` }, + params: { user_id: session.user_id }, + }); + expect(info.ok(), `Read own user with dashboard session: HTTP ${info.status()}`).toBe(true); + expect((await info.json()).user_id).toBe(session.user_id); +} diff --git a/tests/e2e/ui/tests/internal-user/internalUserKeyScope.spec.ts b/tests/e2e/ui/tests/internal-user/internalUserKeyScope.spec.ts index f923841257a..c2004bff7f0 100644 --- a/tests/e2e/ui/tests/internal-user/internalUserKeyScope.spec.ts +++ b/tests/e2e/ui/tests/internal-user/internalUserKeyScope.spec.ts @@ -1,3 +1,4 @@ +import { expectUnrestrictedDashboard, setInvitedUserPassword } from "../../helpers/userOnboarding"; import { test, expect, type APIRequestContext } from "@playwright/test"; import { Page } from "../../fixtures/pages"; import { @@ -76,10 +77,7 @@ test.describe("Internal User - own team key model scope", () => { user_role: "internal_user", auto_create_key: false, }); - await postAsMaster(request, "/user/update", { - user_id: userId, - password: MEMBER_PASSWORD, - }); + await setInvitedUserPassword(request, userId, MEMBER_PASSWORD); await postAsMaster(request, "/team/member_add", { team_id: teamId, member: { role: "user", user_id: userId }, @@ -99,10 +97,7 @@ test.describe("Internal User - own team key model scope", () => { .getByPlaceholder("Enter your password") .fill(MEMBER_PASSWORD); await page.getByRole("button", { name: "Login", exact: true }).click(); - await expect( - page.locator("a", { hasText: "Virtual Keys" }), - `${email} never reached the dashboard`, - ).toBeVisible({ timeout: 30_000 }); + await expectUnrestrictedDashboard(page); await dismissFeedbackPopup(page); await navigateToPage(page, Page.ApiKeys); diff --git a/tests/e2e/ui/tests/proxy-admin/secondAdmin.spec.ts b/tests/e2e/ui/tests/proxy-admin/secondAdmin.spec.ts index 73263c844fa..4572f71b3db 100644 --- a/tests/e2e/ui/tests/proxy-admin/secondAdmin.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/secondAdmin.spec.ts @@ -1,3 +1,4 @@ +import { expectUnrestrictedDashboard, setInvitedUserPassword } from "../../helpers/userOnboarding"; import { test, expect } from "@playwright/test"; import { ADMIN_STORAGE_PATH } from "../../constants"; import { Page } from "../../fixtures/pages"; @@ -46,19 +47,13 @@ test.describe("Second proxy admin", () => { const userId = await inviteAdminUser(); try { - const passwordRes = await request.post("/user/update", { - headers: auth, - data: { user_email: email, password }, - }); - expect(passwordRes.ok(), `setting password failed (${passwordRes.status()}): ${await passwordRes.text()}`).toBe( - true, - ); + await setInvitedUserPassword(request, userId, password); await page.goto("/ui/login"); await page.getByPlaceholder("Enter your username").fill(email); await page.getByPlaceholder("Enter your password").fill(password); await page.getByRole("button", { name: "Login", exact: true }).click(); - await expect(page.locator("a", { hasText: "Virtual Keys" })).toBeVisible({ timeout: 30_000 }); + await expectUnrestrictedDashboard(page); await dismissFeedbackPopup(page); await navigateToPage(page, Page.ApiKeys); diff --git a/tests/e2e/ui/tests/team-admin/memberPermissions.spec.ts b/tests/e2e/ui/tests/team-admin/memberPermissions.spec.ts index 75fb3be9b64..b7978fae7fb 100644 --- a/tests/e2e/ui/tests/team-admin/memberPermissions.spec.ts +++ b/tests/e2e/ui/tests/team-admin/memberPermissions.spec.ts @@ -1,3 +1,4 @@ +import { expectUnrestrictedDashboard, setInvitedUserPassword } from "../../helpers/userOnboarding"; import { test, expect, type Browser, type BrowserContext, type Page as PlaywrightPage } from "@playwright/test"; import { Page } from "../../fixtures/pages"; import { navigateToPage, dismissFeedbackPopup, clickTeamId } from "../../helpers/navigation"; @@ -24,7 +25,7 @@ async function signIn(browser: Browser, email: string): Promise await page.getByPlaceholder("Enter your username").fill(email); await page.getByPlaceholder("Enter your password").fill(PASSWORD); await page.getByRole("button", { name: "Login", exact: true }).click(); - await expect(page.locator("a", { hasText: "Virtual Keys" })).toBeVisible({ timeout: 30_000 }); + await expectUnrestrictedDashboard(page); await dismissFeedbackPopup(page); return context; } @@ -49,11 +50,7 @@ test.describe("Team Admin - Member permissions", () => { data: { user_id: userId, user_email: email, user_role: "internal_user", auto_create_key: false }, }); expect(created.ok(), `POST /user/new for ${userId} (${created.status()}): ${await created.text()}`).toBe(true); - const password = await request.post("/user/update", { - headers: auth(), - data: { user_id: userId, password: PASSWORD }, - }); - expect(password.ok(), `POST /user/update for ${userId} (${password.status()})`).toBe(true); + await setInvitedUserPassword(request, userId, PASSWORD); }; let teamId = "";