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); }); });