bstein-dev-home/testing/frontend/unit/request-access-flow.spec.js

332 lines
12 KiB
JavaScript
Raw Permalink Normal View History

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: "<div />",
}));
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();
});
});