From 86c4133a16ca58d89e2f42c826f1515b9c1896ed Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 8 Aug 2026 22:22:06 -0700 Subject: [PATCH] test(e2e-ui): verify team-admin member and key mutations against the API The team-admin flows stopped at a success toast. A member add that lands on the wrong team, a remove that takes out the wrong row, and a key that comes back unscoped all produce the same toast as the working case, so the existing assertions could not tell them apart. Each mutation now pins what went on the wire and reads the result back: member add/remove assert team_id and the member identifier on the request, then poll /team/info's roster; the team key asserts team_id on /key/generate and reads /key/list back to confirm the key is owned by the admin's own team rather than orphaned. --- .../e2e/ui/tests/team-admin/teamAdmin.spec.ts | 89 ++++++++++++++++--- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts index 18b43ec89b2..c790c1393d1 100644 --- a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts +++ b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page as PlaywrightPage } from "@playwright/test"; import { E2E_INTERNAL_USER_KEY_ALIAS, E2E_TEAM_CRUD_ALIAS, @@ -6,13 +6,36 @@ import { TEAM_ADMIN_STORAGE_PATH, } from "../../constants"; import { Page } from "../../fixtures/pages"; -import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation"; +import { navigateToPage, dismissFeedbackPopup, clickTeamId } from "../../helpers/navigation"; +import { captureRequestBody, readBack } from "../../helpers/roundTrip"; -async function clickTeamId(page: import("@playwright/test").Page, teamId: string) { - const cell = page.locator("td").filter({ hasText: teamId }).first(); - await expect(cell).toBeVisible({ timeout: 10_000 }); - await cell.click(); - await expect(page.getByText("Back to Teams")).toBeVisible({ timeout: 10_000 }); +/** + * Every identifier a team's roster is addressable by. + * + * members_with_roles carries both `user_id` and `user_email`, and which one is + * populated depends on how the member got there: users seeded into the team + * have a user_id and a null email, users added through the UI's email search + * carry both. Flattening the two means the membership assertions can name + * whichever identifier the test actually typed, instead of guessing which + * column the write happened to fill in. + */ +async function teamMemberIdentities(page: PlaywrightPage, teamId: string): Promise { + const info = await readBack<{ team_info: { members_with_roles?: { user_id?: string; user_email?: string }[] } }>( + page, + `/team/info?team_id=${encodeURIComponent(teamId)}`, + ); + return (info.team_info.members_with_roles ?? []).flatMap((member) => + [member.user_id, member.user_email].filter((value): value is string => Boolean(value)), + ); +} + +/** See keys.spec.ts -- return_full_object is what makes the row carry team_id. */ +async function findKeyByAlias(page: PlaywrightPage, alias: string): Promise | undefined> { + const body = await readBack<{ keys: Record[] }>( + page, + `/key/list?key_alias=${encodeURIComponent(alias)}&return_full_object=true&size=100`, + ); + return body.keys.find((row) => row.key_alias === alias); } test.describe("Team Admin", () => { @@ -56,9 +79,25 @@ test.describe("Team Admin", () => { await expect(emailOption).toBeAttached({ timeout: 10_000 }); await page.keyboard.press("Enter"); - await modal.getByRole("button", { name: /Add Member/i }).click(); + const add = await captureRequestBody(page, { method: "POST", urlIncludes: "/team/member_add" }, async () => { + await modal.getByRole("button", { name: /Add Member/i }).click(); + }); + // teamMemberAddCall posts {team_id, member}. A team admin's add must land + // on their own team -- an add that reaches the API with the wrong team_id + // still toasts success, and the member turns up somewhere else. + expect(add.team_id, "add targets the team being viewed").toBe(E2E_TEAM_CRUD_ID); + expect(add.member?.user_email, "the typed email is what goes on the wire").toBe("invitable-team@test.local"); await expect(page.getByText("Team member added successfully").first()).toBeVisible({ timeout: 10_000 }); + + // The toast is the UI reporting on its own request. Membership is the point + // of the flow, so read the roster back. + await expect + .poll(async () => await teamMemberIdentities(page, E2E_TEAM_CRUD_ID), { + message: "added member never appeared in the team's roster", + timeout: 15_000, + }) + .toContain("invitable-team@test.local"); }); test("Team admin can remove a member from their team", async ({ page }) => { @@ -77,9 +116,28 @@ test.describe("Team Admin", () => { const modal = page.locator(".ant-modal:visible"); await expect(modal).toBeVisible({ timeout: 5_000 }); - await modal.getByRole("button", { name: /^Delete$/ }).click(); + + const remove = await captureRequestBody(page, { method: "POST", urlIncludes: "/team/member_delete" }, async () => { + await modal.getByRole("button", { name: /^Delete$/ }).click(); + }); + // teamMemberDeleteCall posts {team_id, user_id?, user_email?}. Removing the + // wrong member is the failure that a success toast hides completely, so + // pin both halves of the target. + expect(remove.team_id, "delete targets the team being viewed").toBe(E2E_TEAM_CRUD_ID); + expect([remove.user_id, remove.user_email], "delete identifies the member whose row was clicked").toContain( + "e2e-removable-member", + ); await expect(page.getByText("Team member removed successfully").first()).toBeVisible({ timeout: 10_000 }); + + // The row disappearing is the client dropping it from local state; that + // happens whether or not the removal reached the database. + await expect + .poll(async () => await teamMemberIdentities(page, E2E_TEAM_CRUD_ID), { + message: "removed member is still on the team", + timeout: 15_000, + }) + .not.toContain("e2e-removable-member"); }); test("Team admin can create a team key with All Team Models", async ({ page }) => { @@ -103,11 +161,22 @@ test.describe("Team Admin", () => { await page.locator(".ant-select-dropdown:visible").getByText("All Team Models").click(); await page.keyboard.press("Escape"); - await page.getByRole("button", { name: "Create Key", exact: true }).click(); + const generate = await captureRequestBody(page, { method: "POST", urlIncludes: "/key/generate" }, async () => { + await page.getByRole("button", { name: "Create Key", exact: true }).click(); + }); + expect(generate.team_id, "the selected team goes on the wire").toBe(E2E_TEAM_CRUD_ID); await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 }); await page.keyboard.press("Escape"); await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 }); + + // The table row is rendered from the create response the UI already holds, + // so it shows the key as the UI *asked* for it. A key that a team admin + // creates and that comes back unscoped -- or scoped to another team -- is + // both a privilege and a billing problem, and only a read-back sees it. + const persisted = await findKeyByAlias(page, keyName); + expect(persisted, `key ${keyName} readable from /key/list`).toBeTruthy(); + expect(persisted?.team_id, "the key is owned by the team admin's own team").toBe(E2E_TEAM_CRUD_ID); }); });