import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; import { flushPromises, mount, shallowMount } from "@vue/test-utils"; import { defineComponent, nextTick } from "vue"; import { useRequestAccessFlow } from "../../../frontend/src/request-access/useRequestAccessFlow.js"; import RequestAccessView from "../../../frontend/src/views/RequestAccessView.vue"; let mockRoute = { query: {} }; jest.mock("vue-router", () => ({ useRoute: () => mockRoute, }), { virtual: true }); function jsonResponse(body, status = 200, statusText = "OK") { return new Response(JSON.stringify(body), { status, statusText, headers: { "content-type": "application/json" }, }); } function mountFlow(route = { query: {} }) { let flow; const wrapper = mount(defineComponent({ setup() { flow = useRequestAccessFlow(route); return {}; }, template: "
", })); return { flow, wrapper }; } async function flushTimersAndPromises(ms = 0) { if (ms) jest.advanceTimersByTime(ms); await Promise.resolve(); await flushPromises(); } describe("request access flow", () => { beforeEach(() => { jest.useFakeTimers(); mockRoute = { query: {} }; global.fetch = jest.fn(); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: jest.fn(async () => {}) }, }); }); afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); Reflect.deleteProperty(global, "fetch"); }); it("maps backend statuses to readable labels and pill classes", () => { const { flow, wrapper } = mountFlow(); expect(flow.statusLabel("pending_email_verification")).toBe("confirm email"); expect(flow.statusLabel("pending")).toBe("awaiting approval"); expect(flow.statusLabel("accounts_building")).toBe("accounts building"); expect(flow.statusLabel("awaiting_onboarding")).toBe("awaiting onboarding"); expect(flow.statusLabel("ready")).toBe("ready"); expect(flow.statusLabel("denied")).toBe("rejected"); expect(flow.statusLabel("custom")).toBe("custom"); expect(flow.statusLabel("")).toBe("unknown"); expect(flow.statusPillClass("pending_email_verification")).toBe("pill-warn"); expect(flow.statusPillClass("pending")).toBe("pill-wait"); expect(flow.statusPillClass("accounts_building")).toBe("pill-warn"); expect(flow.statusPillClass("awaiting_onboarding")).toBe("pill-ok"); expect(flow.statusPillClass("ready")).toBe("pill-info"); expect(flow.statusPillClass("denied")).toBe("pill-bad"); expect(flow.statusPillClass("custom")).toBe("pill-warn"); expect(flow.taskPillClass("ok")).toBe("pill-ok"); expect(flow.taskPillClass("error")).toBe("pill-bad"); expect(flow.taskPillClass("pending")).toBe("pill-warn"); expect(flow.taskPillClass("other")).toBe("pill-warn"); wrapper.unmount(); }); it("checks username availability and handles validation states", async () => { const { flow, wrapper } = mountFlow(); flow.form.username = "ab"; await nextTick(); expect(flow.availability.label).toBe("invalid"); expect(flow.availability.blockSubmit).toBe(true); flow.form.username = "bad name"; await nextTick(); expect(flow.availability.label).toBe("invalid"); expect(flow.availability.detail).toContain("letters"); fetch .mockResolvedValueOnce(jsonResponse({ available: true })) .mockResolvedValueOnce(jsonResponse({ available: false, reason: "exists" })) .mockResolvedValueOnce(jsonResponse({ available: false, reason: "requested", status: "pending" })) .mockResolvedValueOnce(jsonResponse({ available: false, reason: "invalid", detail: "Nope" })) .mockResolvedValueOnce(jsonResponse({}, 503, "Unavailable")); flow.form.username = "alice"; await nextTick(); await flushPromises(); await flushTimersAndPromises(350); expect(flow.availability.label).toBe("available"); expect(flow.availability.detail).toBe("Username is available."); expect(flow.availability.checking).toBe(false); flow.form.username = "taken"; await nextTick(); await flushTimersAndPromises(350); expect(flow.availability.label).toBe("taken"); expect(flow.availability.blockSubmit).toBe(true); flow.form.username = "pending"; await nextTick(); await flushTimersAndPromises(350); expect(flow.availability.label).toBe("requested"); expect(flow.availability.detail).toBe("Existing request: awaiting approval"); flow.form.username = "invalid"; await nextTick(); await flushTimersAndPromises(350); expect(flow.availability.label).toBe("invalid"); expect(flow.availability.detail).toBe("Nope"); flow.form.username = "offline"; await nextTick(); await flushTimersAndPromises(350); expect(flow.availability.label).toBe("error"); flow.form.username = ""; await nextTick(); expect(flow.availability.label).toBe(""); wrapper.unmount(); }); it("submits new requests and copies the request code", async () => { const { flow, wrapper } = mountFlow(); fetch.mockResolvedValueOnce(jsonResponse({ request_code: "alice~abc", status: "pending_email_verification" })); flow.form.username = " alice "; flow.form.first_name = " Ada "; flow.form.last_name = " Lovelace "; flow.form.email = " ada@example.dev "; flow.form.note = " hello "; await flow.submit(); expect(fetch).toHaveBeenCalledWith("/api/access/request", expect.objectContaining({ method: "POST" })); expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ username: "alice", first_name: "Ada", last_name: "Lovelace", email: "ada@example.dev", note: "hello", }); expect(flow.submitted.value).toBe(true); expect(flow.requestCode.value).toBe("alice~abc"); await flow.copyRequestCode(); expect(navigator.clipboard.writeText).toHaveBeenCalledWith("alice~abc"); expect(flow.copied.value).toBe(true); jest.advanceTimersByTime(1500); expect(flow.copied.value).toBe(false); navigator.clipboard.writeText.mockRejectedValueOnce(new Error("clipboard denied")); await flow.copyRequestCode(); expect(flow.error.value).toBe("clipboard denied"); wrapper.unmount(); }); it("falls back to textarea copy and reports submit failures", async () => { const { flow, wrapper } = mountFlow(); Object.defineProperty(document, "execCommand", { configurable: true, value: jest.fn(() => true), }); Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); fetch.mockResolvedValueOnce(jsonResponse({ error: "bad request" }, 400, "Bad Request")); await flow.submit(); expect(flow.error.value).toBe("bad request"); flow.requestCode.value = "bob~code"; await flow.copyRequestCode(); expect(document.execCommand).toHaveBeenCalledWith("copy"); expect(flow.copied.value).toBe(true); wrapper.unmount(); }); it("checks status, retry tasks, and resend verification flows", async () => { const { flow, wrapper } = mountFlow(); flow.statusForm.request_code = "alice~abc"; fetch .mockResolvedValueOnce(jsonResponse({ status: "pending", email_verified: true, onboarding_url: "/onboarding?code=alice", blocked: true, tasks: [ { task: "keycloak", status: "error", detail: "boom" }, { task: "mail", status: "ok" }, ], })) .mockResolvedValueOnce(jsonResponse({ retry: "queued" })) .mockResolvedValueOnce(jsonResponse({ status: "accounts_building", tasks: [] })) .mockResolvedValueOnce(jsonResponse({ sent: true })); await flow.checkStatus(); expect(flow.status.value).toBe("pending"); expect(flow.verifyBanner.value.title).toBe("Email confirmed"); expect(flow.onboardingUrl.value).toBe("/onboarding?code=alice"); expect(flow.blocked.value).toBe(true); await flow.retryProvisioning(); expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({ request_code: "alice~abc", tasks: ["keycloak"] }); expect(flow.retryMessage.value).toBe("Retry requested. Check again in a moment."); expect(flow.status.value).toBe("accounts_building"); flow.status.value = "pending_email_verification"; await flow.resendVerification(); expect(flow.resendMessage.value).toBe("Verification email sent."); wrapper.unmount(); }); it("reports invalid status, retry, and resend failures", async () => { const { flow, wrapper } = mountFlow(); flow.statusForm.request_code = "not-a-code"; await flow.checkStatus(); expect(flow.error.value).toContain("Request code should look like"); expect(flow.status.value).toBe("unknown"); flow.statusForm.request_code = "alice~abc"; fetch .mockResolvedValueOnce(jsonResponse({ error: "status failed" }, 500, "Server Error")) .mockResolvedValueOnce(jsonResponse({ error: "retry failed" }, 429, "Too Many Requests")) .mockResolvedValueOnce(jsonResponse({ error: "resend failed" }, 429, "Too Many Requests")); await flow.checkStatus(); expect(flow.error.value).toBe("status failed"); expect(flow.tasks.value).toEqual([]); flow.tasks.value = [{ task: "mail", status: "error" }]; await flow.retryProvisioning(); expect(flow.retryMessage.value).toBe("retry failed"); await flow.resendVerification(); expect(flow.resendMessage.value).toBe("resend failed"); wrapper.unmount(); }); it("processes verification links during mount", async () => { fetch .mockResolvedValueOnce(jsonResponse({ status: "pending" })) .mockResolvedValueOnce(jsonResponse({ status: "pending", email_verified: true, tasks: [] })); const { flow, wrapper } = mountFlow({ query: { code: "alice~abc", verify: "token", verified: "1", }, }); await flushPromises(); expect(flow.submitted.value).toBe(true); expect(flow.requestCode.value).toBe("alice~abc"); expect(flow.verifyBanner.value.title).toBe("Email confirmed"); expect(fetch).toHaveBeenCalledWith("/api/access/request/verify", expect.objectContaining({ method: "POST" })); wrapper.unmount(); }); it("renders the request access view states", async () => { mockRoute = { query: {} }; fetch .mockResolvedValueOnce(jsonResponse({ available: true })) .mockResolvedValueOnce(jsonResponse({ request_code: "alice~abc", status: "pending_email_verification" })) .mockResolvedValueOnce(jsonResponse({ status: "awaiting_onboarding", onboarding_url: "/onboarding?code=alice", blocked: true, tasks: [{ task: "mail", status: "error", detail: "smtp offline" }], })); const wrapper = mount(RequestAccessView); expect(wrapper.text()).toContain("Request Access"); await wrapper.find("input[autocomplete='username']").setValue("alice"); await flushTimersAndPromises(350); expect(wrapper.text()).toContain("available"); await wrapper.find("input[autocomplete='family-name']").setValue("Lovelace"); await wrapper.find("input[autocomplete='given-name']").setValue("Ada"); await wrapper.find("input[autocomplete='email']").setValue("ada@example.dev"); await wrapper.find("textarea").setValue("Please approve"); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(wrapper.text()).toContain("Request submitted."); expect(wrapper.text()).toContain("alice~abc"); await wrapper.find(".status-form input").setValue("alice~abc"); await wrapper.find(".status-form button").trigger("click"); await flushPromises(); expect(wrapper.text()).toContain("Automation"); expect(wrapper.text()).toContain("smtp offline"); expect(wrapper.text()).toContain("Continue onboarding"); wrapper.unmount(); }); it("renders verification errors from route query", async () => { mockRoute = { query: { code: "alice~abc", verify_error: "expired%20token" } }; fetch.mockResolvedValueOnce(jsonResponse({ status: "unknown", tasks: [] })); const wrapper = shallowMount(RequestAccessView); await flushPromises(); expect(wrapper.text()).toContain("Email verification failed: expired token"); wrapper.unmount(); }); });