diff --git a/frontend/babel.config.cjs b/frontend/babel.config.cjs index 6ca9ad9..2ce7460 100644 --- a/frontend/babel.config.cjs +++ b/frontend/babel.config.cjs @@ -1,3 +1,5 @@ +const path = require("node:path"); + module.exports = { presets: [ [ @@ -7,4 +9,5 @@ module.exports = { }, ], ], + plugins: [path.resolve(__dirname, "../testing/frontend/babel-plugin-import-meta-env.cjs")], }; diff --git a/testing/frontend/babel-plugin-import-meta-env.cjs b/testing/frontend/babel-plugin-import-meta-env.cjs new file mode 100644 index 0000000..8b3ce04 --- /dev/null +++ b/testing/frontend/babel-plugin-import-meta-env.cjs @@ -0,0 +1,20 @@ +module.exports = function importMetaEnvPlugin({ types: t }) { + return { + name: "atlas-import-meta-env-test-plugin", + visitor: { + MetaProperty(path) { + if (path.node.meta.name !== "import" || path.node.property.name !== "meta") return; + path.replaceWith(t.objectExpression([ + t.objectProperty( + t.identifier("env"), + t.logicalExpression( + "||", + t.memberExpression(t.identifier("globalThis"), t.identifier("__ATLAS_IMPORT_META_ENV__")), + t.objectExpression([]), + ), + ), + ])); + }, + }, + }; +}; diff --git a/testing/frontend/jest.config.cjs b/testing/frontend/jest.config.cjs index 7ad6b7c..e2cede1 100644 --- a/testing/frontend/jest.config.cjs +++ b/testing/frontend/jest.config.cjs @@ -17,6 +17,7 @@ module.exports = { "^@/assets/.*\\.(jpg|jpeg|png|gif|webp|svg)$": path.resolve(__dirname, "mocks/file.js"), "\\.(jpg|jpeg|png|gif|webp|svg)$": path.resolve(__dirname, "mocks/file.js"), "^@/(.*)$": path.resolve(frontendRoot, "src/$1"), + "^vue$": path.resolve(frontendRoot, "node_modules/vue/dist/vue.cjs.js"), "^@vue/test-utils$": path.resolve(frontendRoot, "node_modules/@vue/test-utils/dist/vue-test-utils.cjs.js"), "^keycloak-js$": path.resolve(__dirname, "mocks/keycloak-js.js"), "^mermaid$": path.resolve(__dirname, "mocks/mermaid.js"), diff --git a/testing/frontend/unit/request-access-flow.spec.js b/testing/frontend/unit/request-access-flow.spec.js new file mode 100644 index 0000000..1e4587a --- /dev/null +++ b/testing/frontend/unit/request-access-flow.spec.js @@ -0,0 +1,331 @@ +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(); + }); +});