@@ -104,6 +106,7 @@ export function VMAccessRequestPage() {
);
diff --git a/client/src/pages/VMRequestPage.tsx b/client/src/pages/VMRequestPage.tsx
index 758e4cf..6a036ca 100644
--- a/client/src/pages/VMRequestPage.tsx
+++ b/client/src/pages/VMRequestPage.tsx
@@ -1,29 +1,32 @@
import { ArrowLeft } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
-import { RequestErrorCard } from "@/components/shared/RequestErrorCard";
import { RequestSuccessCard } from "@/components/shared/RequestSuccessCard";
import { Button } from "@/components/ui/button";
import { VMRequestForm } from "@/components/vm-request/VMRequestForm";
import { useAuth } from "@/hooks/useAuth";
import { submitVMRequest } from "@/lib/api";
+import { handleSubmissionFailure } from "@/lib/submission-error";
+import { cleanProjectDetails } from "@/services/vm-requests";
import type { VMRequest } from "@/types/vm-request";
export function VMRequestPage() {
const navigate = useNavigate();
const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitFailed, setSubmitFailed] = useState(false);
const [submitResult, setSubmitResult] = useState<{
success: boolean;
requestId?: string;
ticketUrl?: string | null;
- error?: string;
} | null>(null);
- const handleSubmit = async (data: VMRequest) => {
+ const handleSubmit = async (rawData: VMRequest) => {
if (!user) return;
+ const data = cleanProjectDetails(rawData);
setIsSubmitting(true);
+ setSubmitFailed(false);
try {
const response = await submitVMRequest({
...data,
@@ -42,15 +45,14 @@ export function VMRequestPage() {
ticketUrl: response.data.ticketUrl,
});
} else {
- setSubmitResult({
- success: false,
- error: response.error ?? "Failed to submit request",
+ handleSubmissionFailure("VM Request", data, true, setSubmitFailed, {
+ apiError: response.error,
+ statusCode: response.statusCode,
});
}
- } catch {
- setSubmitResult({
- success: false,
- error: "An unexpected error occurred",
+ } catch (error) {
+ handleSubmissionFailure("VM Request", data, true, setSubmitFailed, {
+ caughtError: error,
});
} finally {
setIsSubmitting(false);
@@ -73,16 +75,6 @@ export function VMRequestPage() {
);
}
- if (submitResult?.success === false) {
- return (
-
setSubmitResult(null)}
- onBack={() => navigate("/")}
- />
- );
- }
-
return (
@@ -101,7 +93,11 @@ export function VMRequestPage() {
-
+
);
}
diff --git a/client/src/services/vm-requests.ts b/client/src/services/vm-requests.ts
index c9e78b3..0a69976 100644
--- a/client/src/services/vm-requests.ts
+++ b/client/src/services/vm-requests.ts
@@ -34,9 +34,24 @@ export interface VMRequestListItem {
created_at: string;
}
+export function cleanProjectDetails(data: VMRequest): VMRequest {
+ const { ipraktikum, thesis, chairProject, ...rest } = data;
+ return {
+ ...rest,
+ ...(data.projectType === "ipraktikum" && ipraktikum ? { ipraktikum } : {}),
+ ...(data.projectType === "thesis" && thesis ? { thesis } : {}),
+ ...(data.projectType === "chair_project" && chairProject
+ ? { chairProject }
+ : {}),
+ };
+}
+
export const vmRequestsService = {
create: async (data: VMRequest): Promise => {
- return api.post("/vm-requests", data);
+ return api.post(
+ "/vm-requests",
+ cleanProjectDetails(data),
+ );
},
list: async (): Promise => {
diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts
index d3a8334..9bdbc4c 100644
--- a/e2e/fixtures/auth.ts
+++ b/e2e/fixtures/auth.ts
@@ -83,8 +83,8 @@ async function setupOidcInterception(page: Page): Promise {
// Intercept OIDC discovery
await page.route(
`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration`,
- (route) => {
- route.fulfill({
+ async (route) => {
+ await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(MOCK_OIDC_CONFIG),
@@ -95,8 +95,8 @@ async function setupOidcInterception(page: Page): Promise {
// Intercept JWKS endpoint
await page.route(
`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs`,
- (route) => {
- route.fulfill({
+ async (route) => {
+ await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ keys: [] }),
@@ -105,8 +105,8 @@ async function setupOidcInterception(page: Page): Promise {
);
// Intercept session iframe
- await page.route("**/login-status-iframe.html**", (route) => {
- route.fulfill({
+ await page.route("**/login-status-iframe.html**", async (route) => {
+ await route.fulfill({
status: 200,
contentType: "text/html",
body: "",
@@ -116,8 +116,8 @@ async function setupOidcInterception(page: Page): Promise {
// Intercept token endpoint (for silent renew)
await page.route(
`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`,
- (route) => {
- route.fulfill({
+ async (route) => {
+ await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({ error: "invalid_grant", error_description: "Session not active" }),
@@ -128,8 +128,8 @@ async function setupOidcInterception(page: Page): Promise {
// Intercept userinfo endpoint
await page.route(
`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/userinfo`,
- (route) => {
- route.fulfill({
+ async (route) => {
+ await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
@@ -146,7 +146,7 @@ async function setupOidcInterception(page: Page): Promise {
);
// Block any other requests to Keycloak to prevent hangs
- await page.route(`${KEYCLOAK_URL}/**`, (route) => {
+ await page.route(`${KEYCLOAK_URL}/**`, async (route) => {
const url = route.request().url();
if (
url.includes(".well-known/openid-configuration") ||
@@ -155,10 +155,10 @@ async function setupOidcInterception(page: Page): Promise {
url.includes("/token") ||
url.includes("/userinfo")
) {
- route.fallback();
+ await route.fallback();
return;
}
- route.fulfill({
+ await route.fulfill({
status: 200,
contentType: "text/html",
body: "mocked",
@@ -167,10 +167,10 @@ async function setupOidcInterception(page: Page): Promise {
}
async function setupGitHubMock(page: Page): Promise {
- await page.route("**/api.github.com/users/**", (route) => {
+ await page.route("**/api.github.com/users/**", async (route) => {
const url = route.request().url();
const username = url.split("/users/")[1];
- route.fulfill({
+ await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
diff --git a/e2e/fixtures/test-data.ts b/e2e/fixtures/test-data.ts
index 9a43835..4340a24 100644
--- a/e2e/fixtures/test-data.ts
+++ b/e2e/fixtures/test-data.ts
@@ -123,6 +123,22 @@ export const VM_REQUEST_CONFIGS: Record = {
sshKeyType: "existing",
additionalComments: "Please configure Docker runtime",
},
+ // Reproduction of GitHub issue #1: chair project with public ports and low resources
+ issue_1_chair_project_with_public_ports: {
+ hostname: "e2e-issue1-vm",
+ description: "VM for the research study on ML interpretability",
+ projectType: "chair_project",
+ projectName: "ml-interpretability",
+ projectDescription: "A study to compare different ML explanation methods.",
+ responsiblePerson: "Jane Doe",
+ cpuCores: 2,
+ additionalPorts: [
+ { port: 80, protocol: "tcp", reason: "Web server", publicAccess: true, publicJustification: "Standard" },
+ { port: 443, protocol: "tcp", reason: "HTTPS server", publicAccess: true, publicJustification: "Standard" },
+ ],
+ additionalUsers: ["user-one", "user-two"],
+ sshKeyType: "new",
+ },
};
// ── VM Access Request test data ───────────────────────────────────────────
diff --git a/e2e/helpers/form-fillers.ts b/e2e/helpers/form-fillers.ts
index c651760..223e4f2 100644
--- a/e2e/helpers/form-fillers.ts
+++ b/e2e/helpers/form-fillers.ts
@@ -14,7 +14,7 @@ import { SERVER_URL } from "../playwright.config";
// ── Navigation helpers ────────────────────────────────────────────────────
-async function navigateFromHome(page: Page, cardTitle: string): Promise {
+export async function navigateFromHome(page: Page, cardTitle: string): Promise {
await page.addInitScript(() => {
localStorage.setItem("aet-request.whats-new.v1.dismissed", "true");
});
@@ -26,7 +26,7 @@ async function navigateFromHome(page: Page, cardTitle: string): Promise {
// ── Shared helpers ────────────────────────────────────────────────────────
-async function clickNext(page: Page): Promise {
+export async function clickNext(page: Page): Promise {
await page.getByRole("button", { name: "Next" }).click();
// Wait for step transition
await page.waitForTimeout(300);
@@ -57,7 +57,7 @@ async function selectShadcnOption(
await page.getByRole("option", { name: optionText, exact: true }).click();
}
-async function fillNewSSHKey(page: Page): Promise {
+export async function fillNewSSHKey(page: Page): Promise {
await selectRadioCard(page, "new");
await page.getByPlaceholder("e.g., My Laptop, Work Desktop").fill(TEST_SSH_KEY_NAME);
await page.getByPlaceholder("ssh-ed25519 AAAA").fill(TEST_SSH_PUBLIC_KEY);
diff --git a/e2e/tests/vm-request-edge-cases.spec.ts b/e2e/tests/vm-request-edge-cases.spec.ts
new file mode 100644
index 0000000..2c582ad
--- /dev/null
+++ b/e2e/tests/vm-request-edge-cases.spec.ts
@@ -0,0 +1,399 @@
+import type { Page } from "@playwright/test";
+import { test, expect } from "../fixtures/auth";
+import { VM_REQUEST_CONFIGS } from "../fixtures/test-data";
+import { resetTestState, getLatestTicket } from "../helpers/debug-api";
+import {
+ clickNext,
+ fillNewSSHKey,
+ fillVMRequestForm,
+ navigateFromHome,
+} from "../helpers/form-fillers";
+import { SERVER_URL } from "../playwright.config";
+
+async function navigateToVMForm(page: Page) {
+ await navigateFromHome(page, "Request a New VM");
+ await page.getByText("Basic Information").waitFor();
+}
+
+async function clickNextAndWait(page: Page, nextStepTitle: string) {
+ await clickNext(page);
+ await page.getByRole("heading", { name: nextStepTitle }).waitFor();
+}
+
+async function clickPreviousAndWait(page: Page, prevStepTitle: string) {
+ await page.getByRole("button", { name: "Previous" }).click();
+ await page.getByRole("heading", { name: prevStepTitle }).waitFor();
+}
+
+test.describe("VM Request - Issue #1 Reproduction", () => {
+ test.beforeEach(async ({ request }) => {
+ await resetTestState(request);
+ });
+
+ test("exact issue #1: select thesis then switch to chair_project with public ports", async ({
+ authenticatedPage: page,
+ request,
+ }) => {
+ await navigateToVMForm(page);
+
+ // Step 1: Fill basic info - FIRST select thesis (like the user likely did)
+ await page.getByPlaceholder("my-vm-name").fill("e2e-issue1-vm");
+ await page
+ .getByPlaceholder("Describe what this VM will be used for")
+ .fill("VM for the research study on ML interpretability");
+
+ // Select thesis first and partially fill it
+ await page.locator('label[for="thesis"]').click();
+
+ // Then switch to Chair Project (the user's final choice per the screenshot)
+ await page.locator('label[for="chair_project"]').click();
+ await page.getByPlaceholder("Enter project name").waitFor();
+ await page.getByPlaceholder("Enter project name").fill("ml-interpretability");
+ await page
+ .getByPlaceholder("Describe the chair project")
+ .fill("A study to compare different ML explanation methods.");
+ await page.getByPlaceholder("Enter responsible person").fill("Jane Doe");
+
+ await clickNextAndWait(page, "Resource Configuration");
+
+ // Step 2: Resources - set CPU to 2 (matching the issue screenshot)
+ const cpuInput = page.locator('input[type="number"]').first();
+ await cpuInput.fill("2");
+
+ await clickNextAndWait(page, "Firewall Configuration");
+
+ // Step 3: Firewall - add public ports 80 and 443
+ await page.getByRole("button", { name: /Add Port/i }).click();
+ await page.getByPlaceholder("Why is this port needed?").first().waitFor();
+ const portInputs1 = page.locator('input[type="number"]');
+ await portInputs1.last().fill("80");
+ const protocolSelects1 = page.getByRole("combobox");
+ await protocolSelects1.last().click();
+ await page.getByRole("option", { name: "tcp" }).click();
+ const reasonInputs1 = page.getByPlaceholder("Why is this port needed?");
+ await reasonInputs1.last().fill("Web server");
+ const publicCheckboxes1 = page.getByLabel("Publicly accessible");
+ await publicCheckboxes1.last().check();
+ const justificationInputs1 = page.getByPlaceholder(
+ "Why does this port need to be publicly accessible?",
+ );
+ await justificationInputs1.last().waitFor();
+ await justificationInputs1.last().fill("Standard");
+
+ await page.getByRole("button", { name: /Add Port/i }).click();
+ await expect(page.locator('input[type="number"]')).toHaveCount(2);
+ const portInputs2 = page.locator('input[type="number"]');
+ await portInputs2.last().fill("443");
+ const protocolSelects2 = page.getByRole("combobox");
+ await protocolSelects2.last().click();
+ await page.getByRole("option", { name: "tcp" }).click();
+ const reasonInputs2 = page.getByPlaceholder("Why is this port needed?");
+ await reasonInputs2.last().fill("HTTPS server");
+ const publicCheckboxes2 = page.getByLabel("Publicly accessible");
+ await publicCheckboxes2.last().check();
+ const justificationInputs2 = page.getByPlaceholder(
+ "Why does this port need to be publicly accessible?",
+ );
+ await justificationInputs2.last().waitFor();
+ await justificationInputs2.last().fill("Standard");
+
+ await clickNextAndWait(page, "Additional User Accounts");
+
+ // Step 4: Users
+ await page.getByRole("button", { name: /Add User/i }).click();
+ await page.getByPlaceholder("Enter username").first().waitFor();
+ await page.getByPlaceholder("Enter username").last().fill("user-one");
+ await page.getByRole("button", { name: /Add User/i }).click();
+ await expect(page.getByPlaceholder("Enter username")).toHaveCount(2);
+ await page.getByPlaceholder("Enter username").last().fill("user-two");
+
+ await clickNextAndWait(page, "SSH Key");
+
+ // Step 5: SSH Key
+ await fillNewSSHKey(page);
+ await clickNextAndWait(page, "Review Your Request");
+
+ // Step 6: Submit
+ await page.getByRole("button", { name: "Submit Request" }).click();
+ await page.getByText("Request Submitted!").waitFor({ timeout: 15000 });
+
+ // Verify the ticket has chair project data, NOT thesis data
+ const ticket = await getLatestTicket(request);
+ expect(ticket.summary).toContain("[VM Request]");
+ expect(ticket.summary).toContain("e2e-issue1-vm");
+ expect(ticket.description).toContain("Chair Project");
+ expect(ticket.description).toContain("ml-interpretability");
+ expect(ticket.description).toContain("Jane Doe");
+ expect(ticket.description).toContain("80");
+ expect(ticket.description).toContain("443");
+ expect(ticket.description).toContain("user-one");
+ expect(ticket.description).toContain("user-two");
+ expect(ticket.description).toContain("**CPU Cores:** 2");
+ });
+
+ test("issue #1 config as direct submission (without switching)", async ({
+ authenticatedPage: page,
+ request,
+ }) => {
+ await fillVMRequestForm(page, VM_REQUEST_CONFIGS.issue_1_chair_project_with_public_ports);
+
+ const ticket = await getLatestTicket(request);
+ expect(ticket.summary).toContain("[VM Request]");
+ expect(ticket.summary).toContain("e2e-issue1-vm");
+ expect(ticket.description).toContain("Chair Project");
+ expect(ticket.description).toContain("ml-interpretability");
+ expect(ticket.description).toContain("Jane Doe");
+ expect(ticket.description).toContain("**CPU Cores:** 2");
+ });
+});
+
+test.describe("VM Request - Project Type Switching", () => {
+ test.beforeEach(async ({ request }) => {
+ await resetTestState(request);
+ });
+
+ test("switch from ipraktikum to thesis before submitting", async ({
+ authenticatedPage: page,
+ request,
+ }) => {
+ await navigateToVMForm(page);
+
+ // Step 1: Fill basic info
+ await page.getByPlaceholder("my-vm-name").fill("e2e-switch-to-thesis");
+ await page
+ .getByPlaceholder("Describe what this VM will be used for")
+ .fill("Testing project type switch from ipraktikum to thesis");
+
+ // First select iPraktikum and fill its fields
+ await page.locator('label[for="ipraktikum"]').click();
+ await page.getByPlaceholder("Enter team name").waitFor();
+ await page.getByPlaceholder("Enter team name").fill("Team Switch");
+ await page.getByPlaceholder("Enter coach name").fill("Coach Switch");
+ await page.getByPlaceholder("Enter project lead name").fill("PL Switch");
+
+ // Now switch to thesis
+ await page.locator('label[for="thesis"]').click();
+ await page.getByPlaceholder("Enter thesis title").waitFor();
+ await page.locator('label[for="level-BA"]').click();
+ await page.getByPlaceholder("Enter thesis title").fill("Switched Thesis Title");
+ await page.getByPlaceholder("Enter advisor name").fill("Prof. Switched");
+
+ await clickNextAndWait(page, "Resource Configuration");
+
+ // Step 2: Resources (defaults)
+ await clickNextAndWait(page, "Firewall Configuration");
+
+ // Step 3: Firewall (defaults)
+ await clickNextAndWait(page, "Additional User Accounts");
+
+ // Step 4: Users (none)
+ await clickNextAndWait(page, "SSH Key");
+
+ // Step 5: SSH Key
+ await fillNewSSHKey(page);
+ await clickNextAndWait(page, "Review Your Request");
+
+ // Step 6: Review & Submit
+ await page.getByRole("button", { name: "Submit Request" }).click();
+ await page.getByText("Request Submitted!").waitFor({ timeout: 15000 });
+
+ // Verify the ticket has thesis data, NOT iPraktikum data
+ const ticket = await getLatestTicket(request);
+ expect(ticket.summary).toContain("[VM Request]");
+ expect(ticket.summary).toContain("e2e-switch-to-thesis");
+ expect(ticket.description).toContain("Thesis");
+ expect(ticket.description).toContain("BA");
+ expect(ticket.description).toContain("Switched Thesis Title");
+ expect(ticket.description).toContain("Prof. Switched");
+ expect(ticket.description).not.toContain("Team Switch");
+ });
+
+ test("switch from thesis to chair_project before submitting", async ({
+ authenticatedPage: page,
+ request,
+ }) => {
+ await navigateToVMForm(page);
+
+ await page.getByPlaceholder("my-vm-name").fill("e2e-switch-to-chair");
+ await page
+ .getByPlaceholder("Describe what this VM will be used for")
+ .fill("Testing project type switch from thesis to chair project");
+
+ // First select thesis and fill its fields
+ await page.locator('label[for="thesis"]').click();
+ await page.getByPlaceholder("Enter thesis title").waitFor();
+ await page.locator('label[for="level-MA"]').click();
+ await page.getByPlaceholder("Enter thesis title").fill("Abandoned Thesis");
+ await page.getByPlaceholder("Enter advisor name").fill("Prof. Abandoned");
+
+ // Switch to chair_project
+ await page.locator('label[for="chair_project"]').click();
+ await page.getByPlaceholder("Enter project name").waitFor();
+ await page.getByPlaceholder("Enter project name").fill("Final Chair Project");
+ await page
+ .getByPlaceholder("Describe the chair project")
+ .fill("This is the actual project after switching from thesis");
+
+ await clickNextAndWait(page, "Resource Configuration");
+
+ // Steps 2-5: defaults
+ await clickNextAndWait(page, "Firewall Configuration");
+ await clickNextAndWait(page, "Additional User Accounts");
+ await clickNextAndWait(page, "SSH Key");
+
+ // SSH Key
+ await fillNewSSHKey(page);
+ await clickNextAndWait(page, "Review Your Request");
+
+ // Submit
+ await page.getByRole("button", { name: "Submit Request" }).click();
+ await page.getByText("Request Submitted!").waitFor({ timeout: 15000 });
+
+ const ticket = await getLatestTicket(request);
+ expect(ticket.summary).toContain("e2e-switch-to-chair");
+ expect(ticket.description).toContain("Chair Project");
+ expect(ticket.description).toContain("Final Chair Project");
+ expect(ticket.description).not.toContain("Abandoned Thesis");
+ });
+
+ test("switch from chair_project to ipraktikum before submitting", async ({
+ authenticatedPage: page,
+ request,
+ }) => {
+ await navigateToVMForm(page);
+
+ await page.getByPlaceholder("my-vm-name").fill("e2e-switch-to-iprak");
+ await page
+ .getByPlaceholder("Describe what this VM will be used for")
+ .fill("Testing project type switch from chair project to ipraktikum");
+
+ // First select chair_project
+ await page.locator('label[for="chair_project"]').click();
+ await page.getByPlaceholder("Enter project name").waitFor();
+ await page.getByPlaceholder("Enter project name").fill("Old Project");
+ await page
+ .getByPlaceholder("Describe the chair project")
+ .fill("This will be abandoned");
+
+ // Switch to ipraktikum
+ await page.locator('label[for="ipraktikum"]').click();
+ await page.getByPlaceholder("Enter team name").waitFor();
+ await page.getByPlaceholder("Enter team name").fill("Team Final");
+ await page.getByPlaceholder("Enter coach name").fill("Coach Final");
+ await page.getByPlaceholder("Enter project lead name").fill("PL Final");
+
+ await clickNextAndWait(page, "Resource Configuration");
+
+ // Steps 2-5: defaults
+ await clickNextAndWait(page, "Firewall Configuration");
+ await clickNextAndWait(page, "Additional User Accounts");
+ await clickNextAndWait(page, "SSH Key");
+
+ // SSH Key
+ await fillNewSSHKey(page);
+ await clickNextAndWait(page, "Review Your Request");
+
+ // Submit
+ await page.getByRole("button", { name: "Submit Request" }).click();
+ await page.getByText("Request Submitted!").waitFor({ timeout: 15000 });
+
+ const ticket = await getLatestTicket(request);
+ expect(ticket.summary).toContain("e2e-switch-to-iprak");
+ expect(ticket.description).toContain("iPraktikum");
+ expect(ticket.description).toContain("Team Final");
+ expect(ticket.description).not.toContain("Old Project");
+ });
+});
+
+test.describe("VM Request - Error Handling Preserves Form Data", () => {
+ test.beforeEach(async ({ request }) => {
+ await resetTestState(request);
+ });
+
+ test("form data is preserved after submission failure and retry succeeds", async ({
+ authenticatedPage: page,
+ request,
+ }) => {
+ await navigateToVMForm(page);
+
+ // Fill the full form
+ await page.getByPlaceholder("my-vm-name").fill("e2e-error-retry");
+ await page
+ .getByPlaceholder("Describe what this VM will be used for")
+ .fill("Testing error handling preserves form data");
+
+ await page.locator('label[for="ipraktikum"]').click();
+ await page.getByPlaceholder("Enter team name").waitFor();
+ await page.getByPlaceholder("Enter team name").fill("Error Team");
+ await page.getByPlaceholder("Enter coach name").fill("Error Coach");
+ await page.getByPlaceholder("Enter project lead name").fill("Error PL");
+
+ await clickNextAndWait(page, "Resource Configuration");
+ await clickNextAndWait(page, "Firewall Configuration");
+ await clickNextAndWait(page, "Additional User Accounts");
+ await clickNextAndWait(page, "SSH Key");
+
+ // SSH Key
+ await fillNewSSHKey(page);
+ await clickNextAndWait(page, "Review Your Request");
+
+ // Intercept the API call ONCE to simulate server error
+ let intercepted = false;
+ await page.route(`${SERVER_URL}/api/v1/vm-requests`, async (route) => {
+ if (!intercepted && route.request().method() === "POST") {
+ intercepted = true;
+ await route.fulfill({
+ status: 500,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "Internal Server Error" }),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ // Submit - should fail
+ await page.getByRole("button", { name: "Submit Request" }).click();
+
+ // Verify error toast appears
+ await expect(page.getByText(/Submission failed/i)).toBeVisible({
+ timeout: 10000,
+ });
+ await expect(
+ page.getByText("Our server ran into a problem. Please try again later."),
+ ).toBeVisible();
+
+ // Verify the form is still visible (review step should still be showing)
+ await expect(
+ page.getByRole("button", { name: "Retry Submission" }),
+ ).toBeVisible();
+
+ // Navigate back to step 1 to verify data is preserved
+ await clickPreviousAndWait(page, "SSH Key");
+ await clickPreviousAndWait(page, "Additional User Accounts");
+ await clickPreviousAndWait(page, "Firewall Configuration");
+ await clickPreviousAndWait(page, "Resource Configuration");
+ await clickPreviousAndWait(page, "Basic Information");
+
+ // Verify step 1 data is preserved
+ await expect(page.getByPlaceholder("my-vm-name")).toHaveValue(
+ "e2e-error-retry",
+ );
+ await expect(page.getByPlaceholder("Enter team name")).toHaveValue("Error Team");
+
+ // Navigate forward to review and retry submission
+ await clickNextAndWait(page, "Resource Configuration");
+ await clickNextAndWait(page, "Firewall Configuration");
+ await clickNextAndWait(page, "Additional User Accounts");
+ await clickNextAndWait(page, "SSH Key");
+ await clickNextAndWait(page, "Review Your Request");
+
+ // Retry - should succeed now (route interceptor only blocks once)
+ await page.getByRole("button", { name: "Retry Submission" }).click();
+ await page.getByText("Request Submitted!").waitFor({ timeout: 15000 });
+
+ const ticket = await getLatestTicket(request);
+ expect(ticket.summary).toContain("e2e-error-retry");
+ expect(ticket.description).toContain("Error Team");
+ });
+});
diff --git a/server/pyproject.toml b/server/pyproject.toml
index b19ebb4..393b37f 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -43,6 +43,11 @@ ci = [
[project.scripts]
retention-cleanup = "request_server.cli:run_retention"
+[tool.uv]
+# Avoid resolving packages published in the last week. This reduces exposure to compromised
+# releases while keeping routine dependency updates practical.
+exclude-newer = "7d"
+
[tool.uv.build-backend]
module-root = "."
diff --git a/server/request_server/services/descriptions/blocks.py b/server/request_server/services/descriptions/blocks.py
index d521084..8d388b5 100644
--- a/server/request_server/services/descriptions/blocks.py
+++ b/server/request_server/services/descriptions/blocks.py
@@ -151,10 +151,10 @@ def account_info_block(
"""
content = (
f"{username}:\n"
- f"\tname: {name or 'N/A'}\n"
- f"\temail: {email or 'N/A'}\n"
- f"\tpk: {public_key or 'N/A'}\n"
- f"\tpw: {generate_sha512_password()}"
+ f" name: {name or 'N/A'}\n"
+ f" email: {email or 'N/A'}\n"
+ f" pk: {public_key or 'N/A'}\n"
+ f" pw: {generate_sha512_password()}"
)
return code_block(content)
diff --git a/server/uv.lock b/server/uv.lock
index 79fa75b..39018c0 100644
--- a/server/uv.lock
+++ b/server/uv.lock
@@ -2,6 +2,10 @@ version = 1
revision = 3
requires-python = ">=3.12"
+[options]
+exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
+exclude-newer-span = "P7D"
+
[[package]]
name = "alembic"
version = "1.18.4"