332 lines
12 KiB
JavaScript
332 lines
12 KiB
JavaScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|