diff --git a/integration_tests/pages/Header.ts b/integration_tests/pages/Header.ts index c3f69f5d..17e4d3fa 100644 --- a/integration_tests/pages/Header.ts +++ b/integration_tests/pages/Header.ts @@ -4,6 +4,7 @@ import { HEADER_AVATAR_MENU_ID, HEADER_HELP_MENU_ID, HEADER_HELP_MENU_BUTTON_TEST_ID, + HEADER_LOGOUT_BUTTON_TEST_ID, } from "~client/components/Header.locators"; import { TAKE_TOUR_BUTTON_TEST_ID } from "~client/components/GuidedTour.locators"; import { TourModal } from "./TourModal"; @@ -14,6 +15,7 @@ export class Header { takeTourButton = this.helpMenu.getByTestId(TAKE_TOUR_BUTTON_TEST_ID); avatarButton = this.page.getByTestId(HEADER_AVATAR_BUTTON_TEST_ID); avatarMenu = this.page.locator(`#${HEADER_AVATAR_MENU_ID}`); + logoutBtn = this.avatarMenu.getByTestId(HEADER_LOGOUT_BUTTON_TEST_ID); constructor(public page: Page) { this.page = page; @@ -30,6 +32,11 @@ export class Header { await this.avatarButton.click(); } + async logout() { + await this.openAvatarMenu(); + await this.logoutBtn.click(); + } + avatarMenuItem(name: string) { return this.avatarMenu.getByRole("menuitem", { name }); } diff --git a/integration_tests/pages/LoginPage.ts b/integration_tests/pages/LoginPage.ts index a15e9152..8f3634a4 100644 --- a/integration_tests/pages/LoginPage.ts +++ b/integration_tests/pages/LoginPage.ts @@ -9,4 +9,6 @@ export class LoginPage extends BasePage { "[aria-label='toggle password visibility']", ); loginForm = this.page.locator(`#${LOCATOR_LOGIN_FORM}`); + ssoBtn = this.page.getByTestId("oidcLoginBtn"); + registerLink = this.page.getByText("Create an account"); } diff --git a/integration_tests/pages/UserListPage.ts b/integration_tests/pages/UserListPage.ts index 5cfc1f6d..a9b4c3a1 100644 --- a/integration_tests/pages/UserListPage.ts +++ b/integration_tests/pages/UserListPage.ts @@ -5,6 +5,9 @@ import { Table } from "./components/Table"; export class UserListPage extends BasePage { table: Table; + deleteSelectedBtn = this.page.locator( + "button:has([data-testid='DeleteIcon'])", + ); constructor(page: Page) { super(page); diff --git a/integration_tests/pages/components/BuildList.ts b/integration_tests/pages/components/BuildList.ts index 5755f892..91d7b1e3 100644 --- a/integration_tests/pages/components/BuildList.ts +++ b/integration_tests/pages/components/BuildList.ts @@ -14,6 +14,28 @@ export class BuildList { this.page = page; } + // the menu of one build: stop, edit its CI build id, delete + menu = this.page.getByRole("menu"); + ciBuildIdInput = this.page.getByTestId("newCiBuildId"); + + getBuildItem(number: number) { + // "#1" is also the start of "#10" + return this.buildList + .locator(".MuiListItemButton-root") + .filter({ hasText: new RegExp(String.raw`#${number}(?!\d)`) }); + } + + // the menu button only shows while the pointer is over its build + async openMenu(number: number) { + const item = this.getBuildItem(number); + await item.hover(); + await item.getByTestId("MoreVertIcon").click(); + } + + menuItem(name: "Stop" | "Edit CI Build" | "Delete") { + return this.menu.getByRole("menuitem", { name, exact: true }); + } + getBuildLocator(number: number) { return this.buildList.getByText(`#${number}`); } diff --git a/integration_tests/test/buildMenu.spec.ts b/integration_tests/test/buildMenu.spec.ts new file mode 100644 index 00000000..d9c227c7 --- /dev/null +++ b/integration_tests/test/buildMenu.spec.ts @@ -0,0 +1,217 @@ +import { expect } from "@playwright/test"; +import { test } from "fixtures"; +import { + TEST_BUILD_PASSED, + TEST_BUILD_UNRESOLVED, + TEST_PROJECT, +} from "~client/_test/test.data.helper"; +import { + API_URL, + mockApiError, + mockDeleteBuilds, + mockGetBuildDetails, + mockGetBuilds, + mockGetProjects, + mockGetTestRuns, + mockUpdateBuild, +} from "utils/mocks"; + +const project = TEST_PROJECT; +const runningBuild = { ...TEST_BUILD_UNRESOLVED, isRunning: true }; +const finishedBuild = TEST_BUILD_PASSED; + +const isPatchOf = + (id: string) => (request: { url(): string; method(): string }) => + request.url() === `${API_URL}/builds/${id}` && request.method() === "PATCH"; + +test.beforeEach(async ({ page }) => { + await mockGetProjects(page, [project]); + await mockGetBuilds(page, project.id, [runningBuild, finishedBuild]); + await mockGetBuildDetails(page, runningBuild); + await mockGetBuildDetails(page, finishedBuild); + await mockGetTestRuns(page, runningBuild.id, []); + await mockGetTestRuns(page, finishedBuild.id, []); +}); + +test("renders the build menu", async ({ openProjectPage, page }) => { + const { buildList } = await openProjectPage(project.id); + + await buildList.openMenu(runningBuild.number); + await expect(buildList.menuItem("Stop")).toBeVisible(); + + await expect(page).toHaveScreenshot("build-menu.png"); +}); + +test("renders the CI build id dialog", async ({ openProjectPage, page }) => { + const { buildList, modal } = await openProjectPage(project.id); + + await buildList.openMenu(finishedBuild.number); + await buildList.menuItem("Edit CI Build").click(); + await expect(modal.dialog).toBeVisible(); + + await expect(page).toHaveScreenshot("build-edit-dialog.png"); +}); + +test("stops a running build", async ({ openProjectPage, page }) => { + await mockUpdateBuild(page, runningBuild); + const { buildList } = await openProjectPage(project.id); + + await buildList.openMenu(runningBuild.number); + const [request] = await Promise.all([ + page.waitForRequest(isPatchOf(runningBuild.id)), + buildList.menuItem("Stop").click(), + ]); + + expect(request.postDataJSON()).toEqual({ isRunning: false }); + await expect(buildList.menu).toBeHidden(); + await expect( + page.locator("#notistack-snackbar").filter({ + hasText: `${runningBuild.id} finished`, + }), + ).toBeVisible(); +}); + +test("offers no stop for a finished build", async ({ openProjectPage }) => { + const { buildList } = await openProjectPage(project.id); + + await buildList.openMenu(finishedBuild.number); + + await expect(buildList.menuItem("Edit CI Build")).toBeVisible(); + await expect(buildList.menuItem("Delete")).toBeVisible(); + await expect(buildList.menuItem("Stop")).toHaveCount(0); +}); + +test("changes the CI build id", async ({ openProjectPage, page }) => { + await mockUpdateBuild(page, finishedBuild); + const projectPage = await openProjectPage(project.id); + const { buildList, modal } = projectPage; + + await buildList.openMenu(finishedBuild.number); + await buildList.menuItem("Edit CI Build").click(); + await expect(modal.dialog).toContainText( + `Edit the ci build id for build: #${finishedBuild.number}`, + ); + await buildList.ciBuildIdInput.fill("release-42"); + const [request] = await Promise.all([ + page.waitForRequest(isPatchOf(finishedBuild.id)), + modal.confirmBtn.click(), + ]); + + expect(request.postDataJSON()).toEqual({ ciBuildId: "release-42" }); + await expect(modal.dialog).toBeHidden(); +}); + +test("does not save a CI build id that is too short", async ({ + openProjectPage, + page, +}) => { + const patches: string[] = []; + page.on("request", (request) => { + if (isPatchOf(finishedBuild.id)(request)) { + patches.push(request.url()); + } + }); + const { buildList, modal } = await openProjectPage(project.id); + + await buildList.openMenu(finishedBuild.number); + await buildList.menuItem("Edit CI Build").click(); + await buildList.ciBuildIdInput.fill("r"); + await modal.confirmBtn.click(); + + await expect( + modal.dialog.getByText("Enter at least two characters."), + ).toBeVisible(); + expect(patches).toEqual([]); +}); + +test("opens nothing by itself after a failed CI build id change", async ({ + openProjectPage, + page, +}) => { + await mockApiError( + page, + (url) => url.pathname === `/builds/${finishedBuild.id}`, + 500, + "Could not save", + ); + const { buildList, modal, notification } = await openProjectPage(project.id); + + await buildList.openMenu(finishedBuild.number); + await buildList.menuItem("Edit CI Build").click(); + await buildList.ciBuildIdInput.fill("release-42"); + await modal.confirmBtn.click(); + await expect(notification.message).toHaveText("Could not save"); + + // the next menu opens as a menu, not straight into the old edit dialog + await buildList.openMenu(runningBuild.number); + await expect(buildList.menu).toBeVisible(); + await expect(modal.dialog).toHaveCount(0); +}); + +test("deletes a build", async ({ openProjectPage, page }) => { + await mockDeleteBuilds(page); + const { buildList, modal } = await openProjectPage(project.id); + + await buildList.openMenu(finishedBuild.number); + await buildList.menuItem("Delete").click(); + await expect(modal.dialog).toContainText( + `Are you sure you want to delete build: #${finishedBuild.number}?`, + ); + await Promise.all([ + page.waitForRequest( + (request) => + request.url() === `${API_URL}/builds/${finishedBuild.id}` && + request.method() === "DELETE", + ), + modal.confirmBtn.click(), + ]); + + await expect( + page.locator("#notistack-snackbar").filter({ + hasText: `Build #${finishedBuild.number} deleted`, + }), + ).toBeVisible(); + await expect(modal.dialog).toBeHidden(); +}); + +test("lets go of a build once it is deleted", async ({ + openProjectPage, + page, +}) => { + await mockDeleteBuilds(page); + const { buildList, modal } = await openProjectPage( + project.id, + finishedBuild.id, + ); + + await buildList.openMenu(finishedBuild.number); + await buildList.menuItem("Delete").click(); + await modal.confirmBtn.click(); + + await expect(page).toHaveURL( + (url) => + url.pathname === `/${project.id}` && !url.searchParams.has("buildId"), + ); +}); + +test("opens nothing by itself after a failed delete", async ({ + openProjectPage, + page, +}) => { + await mockApiError( + page, + (url) => url.pathname === `/builds/${finishedBuild.id}`, + 500, + "Could not delete", + ); + const { buildList, modal, notification } = await openProjectPage(project.id); + + await buildList.openMenu(finishedBuild.number); + await buildList.menuItem("Delete").click(); + await modal.confirmBtn.click(); + await expect(notification.message).toHaveText("Could not delete"); + + await buildList.openMenu(runningBuild.number); + await expect(buildList.menu).toBeVisible(); + await expect(modal.dialog).toHaveCount(0); +}); diff --git a/integration_tests/test/buildMenu.spec.ts-snapshots/build-edit-dialog.png b/integration_tests/test/buildMenu.spec.ts-snapshots/build-edit-dialog.png new file mode 100644 index 00000000..e0464fb4 Binary files /dev/null and b/integration_tests/test/buildMenu.spec.ts-snapshots/build-edit-dialog.png differ diff --git a/integration_tests/test/buildMenu.spec.ts-snapshots/build-menu.png b/integration_tests/test/buildMenu.spec.ts-snapshots/build-menu.png new file mode 100644 index 00000000..e4b8c970 Binary files /dev/null and b/integration_tests/test/buildMenu.spec.ts-snapshots/build-menu.png differ diff --git a/integration_tests/test/errors.spec.ts b/integration_tests/test/errors.spec.ts index 44c24981..f7f0f53e 100644 --- a/integration_tests/test/errors.spec.ts +++ b/integration_tests/test/errors.spec.ts @@ -100,6 +100,27 @@ test.describe("a failing API", () => { await expect(projectPage.buildList.emptyMessage).toBeHidden(); }); + test("renders the build list that did not load", async ({ + page, + openProjectPage, + }) => { + await mockGetProjects(page, [project]); + await mockApiError( + page, + (url) => url.origin === API_URL && url.pathname === "/builds", + 500, + "Could not load builds", + ); + + const projectPage = await openProjectPage(project.id); + await expect(projectPage.buildList.errorMessage).toBeVisible(); + // the toast would hide on its own partway through the comparison + await projectPage.notification.dismissBtn.click(); + await expect(projectPage.notification.message).toBeHidden(); + + await expect(page).toHaveScreenshot("project-page-builds-error.png"); + }); + test("stops loading when the test runs fail", async ({ page, openProjectPage, diff --git a/integration_tests/test/errors.spec.ts-snapshots/project-page-builds-error.png b/integration_tests/test/errors.spec.ts-snapshots/project-page-builds-error.png new file mode 100644 index 00000000..a5ec9573 Binary files /dev/null and b/integration_tests/test/errors.spec.ts-snapshots/project-page-builds-error.png differ diff --git a/integration_tests/test/login.spec.ts b/integration_tests/test/login.spec.ts index 8e809066..a65019bc 100644 --- a/integration_tests/test/login.spec.ts +++ b/integration_tests/test/login.spec.ts @@ -1,5 +1,8 @@ -import { test } from "fixtures"; +import { AUTH_USER, test } from "fixtures"; import { expect } from "@playwright/test"; +import { LoginPage, ProfilePage } from "pages"; +import { TEST_PROJECT } from "~client/_test/test.data.helper"; +import { API_URL, mockApiError, mockGetProjects, mockLogin } from "utils/mocks"; test("renders", async ({ loginPage }) => { await expect(loginPage.page).toHaveScreenshot("login-page.png"); @@ -29,3 +32,70 @@ test("show and hide password", async ({ loginPage }) => { await loginPage.showPasswordBtn.click(); expect(await loginPage.password.getAttribute("type")).toBe("password"); }); + +test("shows why the login was refused", async ({ loginPage, page }) => { + await mockApiError( + page, + `${API_URL}/users/login`, + 401, + "Invalid email or password", + ); + + await loginPage.email.fill("email@google.com"); + await loginPage.password.fill("wrong password"); + await loginPage.loginBtn.click(); + + await expect(loginPage.notification.message).toHaveText( + "Invalid email or password", + ); + await expect(page).toHaveURL(/\/login$/); + await expect(loginPage.loginForm).toBeVisible(); +}); + +test("does not send an email that is not one", async ({ loginPage, page }) => { + const logins: string[] = []; + page.on("request", (request) => { + if (request.url().endsWith("/users/login")) { + logins.push(request.url()); + } + }); + + await loginPage.email.fill("not an email"); + await loginPage.password.fill("123456"); + await loginPage.loginBtn.click(); + + await expect(page.getByText("Enter a valid email.")).toBeVisible(); + expect(logins).toEqual([]); +}); + +test("returns to the page that asked for the login", async ({ page }) => { + await mockGetProjects(page, [TEST_PROJECT]); + await mockLogin(page, AUTH_USER); + + await page.goto("/profile"); + await expect(page).toHaveURL(/\/login$/); + const loginPage = new LoginPage(page); + await loginPage.email.fill(AUTH_USER.email); + await loginPage.password.fill("123456"); + await loginPage.loginBtn.click(); + + await expect(page).toHaveURL(/\/profile$/); + await expect(new ProfilePage(page).firstName).toHaveValue( + AUTH_USER.firstName, + ); +}); + +test("goes to the projects after a plain login", async ({ + loginPage, + page, +}) => { + await mockGetProjects(page, [TEST_PROJECT]); + await mockLogin(page, AUTH_USER); + + await loginPage.email.fill(AUTH_USER.email); + await loginPage.password.fill("123456"); + await loginPage.loginBtn.click(); + + await expect(page).toHaveURL(/\/projects$/); + await expect(loginPage.header.avatarButton).toBeVisible(); +}); diff --git a/integration_tests/test/oidc.spec.ts b/integration_tests/test/oidc.spec.ts new file mode 100644 index 00000000..a4c77404 --- /dev/null +++ b/integration_tests/test/oidc.spec.ts @@ -0,0 +1,181 @@ +import { expect } from "@playwright/test"; +import { AUTH_USER, test } from "fixtures"; +import { LoginPage, ProfilePage } from "pages"; +import { TEST_PROJECT } from "~client/_test/test.data.helper"; +import { + API_URL, + mockApiError, + mockEnvConfig, + mockGetMe, + mockGetProjects, + mockOidcEnabled, + mockOidcProvider, +} from "utils/mocks"; + +const PROVIDER_URL = `${API_URL}/users/oidc/login`; + +// /users/me answers without the token; the app adds the one it was sent +const ssoUser = { ...AUTH_USER, token: undefined }; + +test.beforeEach(async ({ page }) => { + await mockGetProjects(page, [TEST_PROJECT]); + await mockOidcProvider(page); +}); + +test.describe("with SSO off", () => { + test("shows the password form when the API cannot say", async ({ page }) => { + // an older backend without the endpoint + await mockApiError(page, `${API_URL}/users/oidc/enabled`, 404, "Not Found"); + + await page.goto("/login"); + const loginPage = new LoginPage(page); + + await expect(loginPage.email).toBeVisible(); + await expect(loginPage.registerLink).toBeVisible(); + await expect(loginPage.ssoBtn).toHaveCount(0); + }); +}); + +test.describe("with SSO on", () => { + test.beforeEach(async ({ page }) => { + await mockOidcEnabled(page, true); + }); + + test("renders the SSO login", async ({ page }) => { + await page.goto("/login"); + await expect(new LoginPage(page).ssoBtn).toBeVisible(); + + await expect(page).toHaveScreenshot("login-page-sso.png"); + }); + + test("renders the profile of an SSO user", async ({ profilePage, page }) => { + await expect(profilePage.email).toBeDisabled(); + + await expect(page).toHaveScreenshot("profile-page-sso.png"); + }); + + test("offers SSO instead of the password form", async ({ page }) => { + await page.goto("/login"); + const loginPage = new LoginPage(page); + + await expect(loginPage.ssoBtn).toBeVisible(); + await expect(loginPage.email).toHaveCount(0); + await expect(loginPage.password).toHaveCount(0); + await expect(loginPage.registerLink).toHaveCount(0); + }); + + test("sends the user to the identity provider", async ({ page }) => { + await page.goto("/login"); + + await new LoginPage(page).ssoBtn.click(); + + await expect(page).toHaveURL(PROVIDER_URL); + }); + + test("signs in with the token the provider sends back", async ({ page }) => { + await mockGetMe(page, ssoUser); + + const [request] = await Promise.all([ + page.waitForRequest(`${API_URL}/users/me`), + page.goto("/login#token=sso-token"), + ]); + + expect(request.headers()["authorization"]).toBe("Bearer sso-token"); + // the fragment holding the token is gone from the address + await expect(page).toHaveURL(/\/projects$/); + const stored = await page.evaluate(() => + JSON.parse(window.localStorage.getItem("user") ?? "null"), + ); + expect(stored).toMatchObject({ email: ssoUser.email, token: "sso-token" }); + await expect(new ProfilePage(page).header.avatarButton).toBeVisible(); + }); + + test("reports a login the provider refused", async ({ page }) => { + await page.goto("/login#error=access_denied"); + const loginPage = new LoginPage(page); + + await expect(loginPage.notification.message).toHaveText( + "SSO login failed.", + ); + await expect(page).toHaveURL(/\/login$/); + await expect(loginPage.ssoBtn).toBeVisible(); + }); + + test("reports a token the API does not accept", async ({ page }) => { + await mockApiError(page, `${API_URL}/users/me`, 401, "Unauthorized"); + + await page.goto("/login#token=expired-token"); + const loginPage = new LoginPage(page); + + await expect(loginPage.notification.message).toHaveText("Unauthorized"); + await expect(page).toHaveURL(/\/login$/); + expect( + await page.evaluate(() => window.localStorage.getItem("user")), + ).toBeNull(); + }); + + test("sends registration to the login page", async ({ page }) => { + await page.goto("/register"); + + await expect(page).toHaveURL(/\/login$/); + await expect(new LoginPage(page).ssoBtn).toBeVisible(); + }); + + test("leaves the email and password of an SSO user to the provider", async ({ + profilePage, + }) => { + await expect(profilePage.email).toBeDisabled(); + await expect(profilePage.passwordForm).toHaveCount(0); + // the names are still the user's to change + await expect(profilePage.firstName).toBeEnabled(); + }); +}); + +test.describe("with SSO on and auto redirect", () => { + test.beforeEach(async ({ page }) => { + await mockOidcEnabled(page, true); + await mockEnvConfig(page, { REACT_APP_OIDC_AUTO_REDIRECT: "true" }); + }); + + test("goes straight to the identity provider", async ({ page }) => { + await page.goto("/login"); + + await expect(page).toHaveURL(PROVIDER_URL); + }); + + test("does not bounce back to the provider with its answer", async ({ + page, + }) => { + await page.goto("/login#error=access_denied"); + const loginPage = new LoginPage(page); + + await expect(loginPage.notification.message).toHaveText( + "SSO login failed.", + ); + await expect(page).toHaveURL(/\/login$/); + }); + + test("stays on the login page after logging out", async ({ + projectListPage, + page, + }) => { + const providerRequests: string[] = []; + page.on("request", (request) => { + if (request.url() === PROVIDER_URL) { + providerRequests.push(request.url()); + } + }); + + await projectListPage.header.logout(); + + const loginPage = new LoginPage(page); + await expect(loginPage.ssoBtn).toBeVisible(); + await expect(page).toHaveURL(/\/login$/); + // the provider's session would sign the user straight back in + expect(providerRequests).toEqual([]); + + // asking for SSO again still works + await loginPage.ssoBtn.click(); + await expect(page).toHaveURL(PROVIDER_URL); + }); +}); diff --git a/integration_tests/test/oidc.spec.ts-snapshots/login-page-sso.png b/integration_tests/test/oidc.spec.ts-snapshots/login-page-sso.png new file mode 100644 index 00000000..f3cd16b3 Binary files /dev/null and b/integration_tests/test/oidc.spec.ts-snapshots/login-page-sso.png differ diff --git a/integration_tests/test/oidc.spec.ts-snapshots/profile-page-sso.png b/integration_tests/test/oidc.spec.ts-snapshots/profile-page-sso.png new file mode 100644 index 00000000..ab1d8f56 Binary files /dev/null and b/integration_tests/test/oidc.spec.ts-snapshots/profile-page-sso.png differ diff --git a/integration_tests/test/userList.spec.ts b/integration_tests/test/userList.spec.ts index e8fb0fce..e679610f 100644 --- a/integration_tests/test/userList.spec.ts +++ b/integration_tests/test/userList.spec.ts @@ -1,6 +1,12 @@ import { expect } from "@playwright/test"; -import { test } from "fixtures"; -import { mockGetProjects, mockGetUsers } from "utils/mocks"; +import { AUTH_USER, test } from "fixtures"; +import { UserListPage } from "pages/UserListPage"; +import { + API_URL, + mockDeleteUsers, + mockGetProjects, + mockGetUsers, +} from "utils/mocks"; import { EDITOR_USER, GUEST_USER, @@ -38,3 +44,64 @@ for (const [user, role] of assignRoleCases) { }); }); } + +test("deletes the selected users", async ({ userListPage, page }) => { + await mockDeleteUsers(page); + + await userListPage.table.checkRow(EDITOR_USER.id); + await userListPage.table.checkRow(GUEST_USER.id); + const [request] = await Promise.all([ + page.waitForRequest( + (request) => + request.url() === `${API_URL}/users` && request.method() === "DELETE", + ), + userListPage.deleteSelectedBtn.click(), + ]); + + expect(request.postDataJSON()).toEqual([EDITOR_USER.id, GUEST_USER.id]); + await expect(userListPage.notification.message).toHaveText("Removed"); + await expect(userListPage.table.getRow(EDITOR_USER.id)).toHaveCount(0); + await expect(userListPage.table.getRow(GUEST_USER.id)).toHaveCount(0); + await expect(userListPage.table.getRow(TEST_USER.id)).toBeVisible(); +}); + +test("renders the selected users", async ({ userListPage, page }) => { + await userListPage.table.checkRow(EDITOR_USER.id); + await userListPage.table.checkRow(GUEST_USER.id); + await expect(userListPage.deleteSelectedBtn).toBeEnabled(); + + await expect(page).toHaveScreenshot("user-list-selection.png"); +}); + +test("offers no delete until a user is selected", async ({ userListPage }) => { + await expect(userListPage.table.getRow(TEST_USER.id)).toBeVisible(); + + await expect(userListPage.deleteSelectedBtn).toBeDisabled(); +}); + +test("does not let the signed-in user delete themself", async ({ + page, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + authUser, +}) => { + const self = { ...TEST_USER, ...AUTH_USER, token: undefined }; + await mockGetUsers(page, [self as unknown as User, EDITOR_USER]); + const deletes: string[] = []; + page.on("request", (request) => { + if (request.url() === `${API_URL}/users` && request.method() === "DELETE") { + deletes.push(request.url()); + } + }); + await page.goto("/users"); + const userListPage = new UserListPage(page); + + await userListPage.table.checkRow(self.id); + await userListPage.table.checkRow(EDITOR_USER.id); + await userListPage.deleteSelectedBtn.click(); + + await expect(userListPage.notification.message).toHaveText( + "You cannot delete yourself.", + ); + await expect(userListPage.table.getRow(EDITOR_USER.id)).toBeVisible(); + expect(deletes).toEqual([]); +}); diff --git a/integration_tests/test/userList.spec.ts-snapshots/user-list-selection.png b/integration_tests/test/userList.spec.ts-snapshots/user-list-selection.png new file mode 100644 index 00000000..a897ce95 Binary files /dev/null and b/integration_tests/test/userList.spec.ts-snapshots/user-list-selection.png differ diff --git a/integration_tests/utils/mocks.ts b/integration_tests/utils/mocks.ts index 7a80ffb5..cc5c024c 100644 --- a/integration_tests/utils/mocks.ts +++ b/integration_tests/utils/mocks.ts @@ -76,9 +76,8 @@ export const mockGetBuilds = async ( const take = Number(params.get("take")); const ciBuildId = params.get("ciBuildId"); const matching = ciBuildId - ? builds.filter( - (build) => - build.ciBuildId?.toLowerCase().includes(ciBuildId.toLowerCase()), + ? builds.filter((build) => + build.ciBuildId?.toLowerCase().includes(ciBuildId.toLowerCase()), ) : builds; @@ -229,3 +228,69 @@ export const mockMergeTestVariations = async (page: Page, build: Build) => { (route) => route.fulfill({ body: JSON.stringify(build) }), ); }; + +// the runtime settings env.sh writes to env-config.js, with some overridden; +// the app reads them once, when it loads +export const mockEnvConfig = ( + page: Page, + overrides: Record, +) => { + return page.route("**/env-config.js", async (route) => { + const response = await route.fetch(); + const assignments = Object.entries(overrides) + .map(([key, value]) => `window._env_.${key} = ${JSON.stringify(value)};`) + .join("\n"); + return route.fulfill({ + response, + body: `${await response.text()}\n${assignments}`, + }); + }); +}; + +export const mockOidcEnabled = (page: Page, enabled: boolean) => { + return page.route(`${API_URL}/users/oidc/enabled`, (route) => + route.fulfill({ body: JSON.stringify(enabled) }), + ); +}; + +// stands in for the API's redirect to the identity provider +export const mockOidcProvider = (page: Page) => { + return page.route(`${API_URL}/users/oidc/login`, (route) => + route.fulfill({ + contentType: "text/html", + body: "

Identity provider

", + }), + ); +}; + +// the user behind a token, as /users/me answers: without the token itself +export const mockGetMe = (page: Page, user: object) => { + return page.route(`${API_URL}/users/me`, (route) => + route.fulfill({ body: JSON.stringify(user) }), + ); +}; + +export const mockLogin = (page: Page, user: object) => { + return page.route(`${API_URL}/users/login`, (route) => + route.fulfill({ body: JSON.stringify(user) }), + ); +}; + +export const mockDeleteUsers = (page: Page) => { + return page.route(`${API_URL}/users`, (route, request) => + request.method() === "DELETE" + ? route.fulfill({ body: JSON.stringify(true) }) + : route.fallback(), + ); +}; + +// the API answers a build patch with the build as patched +export const mockUpdateBuild = (page: Page, build: Build) => { + return page.route(`${API_URL}/builds/${build.id}`, (route, request) => + request.method() === "PATCH" + ? route.fulfill({ + body: JSON.stringify({ ...build, ...request.postDataJSON() }), + }) + : route.fallback(), + ); +}; diff --git a/src/components/BuildList/index.tsx b/src/components/BuildList/index.tsx index 64a5ed1b..838eb5e1 100644 --- a/src/components/BuildList/index.tsx +++ b/src/components/BuildList/index.tsx @@ -490,14 +490,16 @@ const BuildList: FunctionComponent = () => { .update(menuBuild.id, { ciBuildId: newCiBuildId, }) - .then(() => { - toggleEditDialogOpen(); - }) .catch((err) => enqueueSnackbar(err, { variant: "error", }), ); + // closed now, not on success: the dialog belongs to this menu's + // build, and left open after a failure it would come back for the + // next build whose menu is opened, holding this build's input + setEditDialogOpen(false); + setNewCiBuildId(""); handleMenuClose(); }} /> @@ -516,7 +518,6 @@ const BuildList: FunctionComponent = () => { onSubmit={() => { deleteBuild(buildDispatch, menuBuild.id) .then(() => { - toggleDeleteDialogOpen(); enqueueSnackbar( `Build #${menuBuild.number || menuBuild.id} deleted`, { @@ -538,6 +539,8 @@ const BuildList: FunctionComponent = () => { variant: "error", }), ); + // closed now, for the same reason as the edit dialog above + setDeleteDialogOpen(false); handleMenuClose(); }} />