test(bstein-home): cover request access frontend
This commit is contained in:
parent
a66913950c
commit
db24e3fe88
@ -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")],
|
||||
};
|
||||
|
||||
20
testing/frontend/babel-plugin-import-meta-env.cjs
Normal file
20
testing/frontend/babel-plugin-import-meta-env.cjs
Normal file
@ -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([]),
|
||||
),
|
||||
),
|
||||
]));
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -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"),
|
||||
|
||||
331
testing/frontend/unit/request-access-flow.spec.js
Normal file
331
testing/frontend/unit/request-access-flow.spec.js
Normal file
@ -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: "<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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user