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

476 lines
20 KiB
JavaScript
Raw Normal View History

import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
import { flushPromises, mount } from "@vue/test-utils";
import { computed, defineComponent, nextTick, ref } from "vue";
import { auth } from "../../../frontend/src/auth.js";
import { parseManifest } from "../../../frontend/src/onboarding/onboardingGuides.js";
import { statusLabel, statusPillClass, taskPillClass } from "../../../frontend/src/onboarding/onboardingLabels.js";
import { SECTION_DEFS, STEP_PREREQS, VAULTWARDEN_TEMP_STEP } from "../../../frontend/src/onboarding/onboardingSections.js";
import { useOnboardingFlow } from "../../../frontend/src/onboarding/useOnboardingFlow.js";
import { useOnboardingGuides } from "../../../frontend/src/onboarding/useOnboardingGuides.js";
import { useOnboardingNavigation } from "../../../frontend/src/onboarding/useOnboardingNavigation.js";
import OnboardingView from "../../../frontend/src/views/OnboardingView.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 manifestResponse() {
return jsonResponse({
files: [
"vaultwarden/step1_website/default/001-login.png",
"vaultwarden/step1_website/default/002-create-vault.png",
"vaultwarden/step1_website/mobile/step-03-phone.webp",
"/element/step1_web_access/001-start.jpg",
"bad",
7,
],
});
}
function readyStatus(overrides = {}) {
return {
status: "awaiting_onboarding",
username: "ada",
initial_password: "temp-pass",
initial_password_revealed_at: "",
blocked: false,
tasks: [{ task: "keycloak", status: "ok" }],
onboarding: {
required_steps: [
"vaultwarden_master_password",
"keycloak_password_rotated",
"element_recovery_key",
"nextcloud_mail_integration",
"firefly_password_rotated",
"wger_password_rotated",
],
optional_steps: ["vaultwarden_browser_extension", "mail_client_setup"],
completed_steps: ["vaultwarden_master_password"],
vaultwarden: { matched: true, recovery_email: "ada@example.dev" },
keycloak: { password_rotation_requested: false },
},
...overrides,
};
}
function installFetch(handler) {
global.fetch = jest.fn(async (url, options = {}) => handler(String(url), options));
}
function mountFlow(route = { query: {} }) {
let flow;
const wrapper = mount(defineComponent({
setup() {
flow = useOnboardingFlow(route);
return {};
},
template: "<div />",
}));
return { flow, wrapper };
}
describe("onboarding helpers and flow", () => {
beforeEach(() => {
jest.useFakeTimers();
mockRoute = { query: {} };
auth.authenticated = false;
auth.token = "";
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: jest.fn(async () => {}) },
});
installFetch((url) => (url.includes("/media/onboarding") ? manifestResponse() : jsonResponse(readyStatus())));
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
auth.authenticated = false;
auth.token = "";
Reflect.deleteProperty(global, "fetch");
});
it("maps labels, static sections, and manifest files", () => {
expect(statusLabel("pending_email_verification")).toBe("confirm email");
expect(statusLabel("pending")).toBe("awaiting approval");
expect(statusLabel("accounts_building")).toBe("accounts building");
expect(statusLabel("awaiting_onboarding")).toBe("awaiting onboarding");
expect(statusLabel("ready")).toBe("ready");
expect(statusLabel("denied")).toBe("rejected");
expect(statusLabel("")).toBe("unknown");
expect(statusPillClass("denied")).toBe("pill-bad");
expect(statusPillClass("ready")).toBe("pill-info");
expect(statusPillClass("other")).toBe("pill-warn");
expect(taskPillClass("ok")).toBe("pill-ok");
expect(taskPillClass("error")).toBe("pill-bad");
expect(taskPillClass("pending")).toBe("pill-warn");
expect(STEP_PREREQS.element_recovery_key).toEqual(["keycloak_password_rotated"]);
expect(SECTION_DEFS.map((section) => section.id)).toContain("vaultwarden");
expect(VAULTWARDEN_TEMP_STEP.id).toBe("vaultwarden_store_temp_password");
const parsed = parseManifest([
"vaultwarden\\step1_website\\default\\002-second.png",
"vaultwarden/step1_website/default/001-first.png",
"vaultwarden/step1_website/default/final-shot.png",
"vaultwarden/step1_website/mobile/step-03-phone.webp",
"ignored",
null,
]);
expect(parsed.vaultwarden.step1_website.default.shots.map((shot) => shot.label)).toEqual(["first", "second", "final shot"]);
expect(parsed.vaultwarden.step1_website.mobile.shots[0].order).toBe(3);
});
it("paginates guide groups and handles lightbox state", async () => {
const done = new Set(["vaultwarden_master_password"]);
const guides = useOnboardingGuides({
isStepDone: (id) => done.has(id),
isStepBlocked: (id) => id === "blocked",
});
await guides.loadGuideShots();
const step = { id: "vaultwarden_browser_extension", guide: { service: "vaultwarden", step: "step1_website", take: 1 } };
const tailStep = { id: "vaultwarden_mobile_app", guide: { service: "vaultwarden", step: "step1_website", tail: 1 } };
const section = { steps: [{ id: "vaultwarden_master_password", guide: step.guide }, step, { id: "blocked", guide: step.guide }] };
expect(guides.guideGroups(step)[0].shots).toHaveLength(1);
expect(guides.guideGroups(tailStep)[0].shots[0].label).toBe("create vault");
const fullStep = { id: "vaultwarden_browser_extension", guide: { service: "vaultwarden", step: "step1_website" } };
const fullGroup = guides.guideGroups(fullStep)[0];
guides.guideNext(fullStep, fullGroup);
expect(guides.guideIndex(fullStep, fullGroup)).toBe(1);
guides.guidePrev(fullStep, fullGroup);
expect(guides.guideIndex(fullStep, fullGroup)).toBe(0);
expect(guides.shouldOpenGuide(step, section)).toBe(true);
guides.guideSet(step, guides.guideGroups(step)[0], 12);
expect(guides.guideIndex(step, guides.guideGroups(step)[0])).toBe(0);
guides.openLightbox(guides.guideShot(step, guides.guideGroups(step)[0]));
expect(guides.lightboxShot.value.url).toContain("001-login");
guides.closeLightbox();
expect(guides.lightboxShot.value).toBeNull();
global.fetch.mockRejectedValueOnce(new Error("offline"));
await guides.loadGuideShots();
expect(guides.guideShots.value).toEqual({});
});
it("navigates onboarding sections with lock and progress rules", () => {
const activeSectionId = ref("first");
const completed = ref(["a"]);
const sections = computed(() => [
{ id: "first", steps: [{ id: "a" }] },
{ id: "second", steps: [{ id: "b" }] },
{ id: "optional", steps: [{ id: "c" }] },
]);
const nav = useOnboardingNavigation({
sections,
activeSectionId,
isStepDone: (id) => completed.value.includes(id),
isStepRequired: (id) => id !== "c",
isStepBlocked: (id) => id === "blocked",
});
expect(nav.sectionProgress(sections.value[0])).toBe("1/1 done");
expect(nav.sectionStatusLabel(sections.value[1])).toBe("active");
nav.nextSection();
expect(activeSectionId.value).toBe("second");
expect(nav.hasPrevSection.value).toBe(true);
expect(nav.stepCardClass({ id: "c" }).optional).toBe(true);
expect(nav.sectionProgress(sections.value[2])).toBe("optional");
nav.selectSection("missing");
expect(activeSectionId.value).toBe("second");
nav.prevSection();
expect(activeSectionId.value).toBe("first");
completed.value = [];
nav.selectSection("second");
expect(activeSectionId.value).toBe("first");
});
it("checks status, copies credentials, retries, and confirms steps", async () => {
installFetch((url) => {
if (url.includes("/media/onboarding")) return manifestResponse();
if (url.includes("/retry")) return jsonResponse({ queued: true });
if (url.includes("/onboarding/attest")) {
return jsonResponse(readyStatus({
onboarding: { ...readyStatus().onboarding, completed_steps: ["vaultwarden_master_password", "mail_client_setup"] },
}));
}
if (url.includes("/keycloak-password-rotate")) {
return jsonResponse(readyStatus({
onboarding: { ...readyStatus().onboarding, keycloak: { password_rotation_requested: true } },
}));
}
return jsonResponse(readyStatus({ tasks: [{ task: "mail", status: "error" }] }));
});
const { flow, wrapper } = mountFlow();
flow.requestCode.value = "ada~abc";
await flow.check();
expect(flow.status.value).toBe("awaiting_onboarding");
expect(flow.requestUsername.value).toBe("ada");
expect(flow.showPasswordCard.value).toBe(true);
expect(flow.vaultwardenLoginEmailLower.value).toBe("ada@example.dev");
expect(flow.mailAddressLower.value).toBe("ada@bstein.dev");
expect(flow.stepNote({ id: "firefly_password_rotated" })).toContain("ada@bstein.dev");
expect(flow.stepPillLabel({ id: "keycloak_password_rotated", action: "confirm" })).toBe("ready");
expect(flow.stepPillClass({ id: "vaultwarden_browser_extension" })).toBe("pill-info");
flow.togglePassword();
expect(flow.revealPassword.value).toBe(true);
await flow.copyInitialPassword();
await flow.copyUsername();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("temp-pass");
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("ada");
jest.advanceTimersByTime(1500);
expect(flow.passwordCopied.value).toBe(false);
await flow.retryProvisioning();
expect(flow.retryMessage.value).toBe("Retry requested. Check again in a moment.");
await flow.setStepCompletion("mail_client_setup", true);
expect(flow.isStepDone("mail_client_setup")).toBe(true);
await flow.requestKeycloakPasswordRotation();
expect(flow.keycloakPasswordRotationRequested.value).toBe(true);
wrapper.unmount();
});
it("handles auth fallback, rotation checks, blocked steps, and failures", async () => {
auth.authenticated = true;
auth.token = "token";
installFetch((url) => {
if (url.includes("/media/onboarding")) return manifestResponse();
if (url.includes("/rotation/check")) return jsonResponse({ rotated: false });
if (url.includes("/onboarding/attest")) return jsonResponse({ error: "forbidden" }, 403, "Forbidden");
if (url.includes("/keycloak-password-rotate")) return jsonResponse({ error: "blocked" }, 409, "Conflict");
if (url.includes("/status")) return jsonResponse(readyStatus({
onboarding: { ...readyStatus().onboarding, completed_steps: [] },
}));
return jsonResponse({});
});
const { flow, wrapper } = mountFlow();
flow.requestCode.value = "ada~abc";
await flow.check();
await flow.setStepCompletion("element_recovery_key", true);
expect(flow.error.value).toBe("");
await flow.setStepCompletion("vaultwarden_master_password", true);
expect(flow.error.value).toBe("forbidden");
flow.error.value = "";
flow.onboarding.value.completed_steps = ["vaultwarden_master_password", "keycloak_password_rotated", "element_recovery_key"];
await flow.confirmStep({ id: "firefly_password_rotated", action: "auto" });
expect(flow.error.value).toContain("Firefly still uses");
await expect(flow.runRotationCheck("wger")).resolves.toEqual({ rotated: false });
flow.keycloakPasswordRotationRequested.value = false;
await flow.requestKeycloakPasswordRotation();
expect(flow.error.value).toBe("blocked");
wrapper.unmount();
});
it("covers onboarding edge branches without hiding workflow failures", async () => {
const responses = { rotateCalls: 0 };
installFetch((url) => {
if (url.includes("/media/onboarding")) return manifestResponse();
if (url.includes("/rotation/check")) return jsonResponse({ rotated: true });
if (url.includes("/onboarding/attest")) return jsonResponse(readyStatus({
onboarding: { ...readyStatus().onboarding, completed_steps: ["vaultwarden_master_password", "mail_client_setup"] },
}));
if (url.includes("/keycloak-password-rotate")) {
responses.rotateCalls += 1;
if (responses.rotateCalls === 1) return jsonResponse({ error: "auth denied" }, 403, "Forbidden");
return jsonResponse(readyStatus({
onboarding: { ...readyStatus().onboarding, keycloak: { password_rotation_requested: true } },
}));
}
if (url.includes("/status")) return jsonResponse(readyStatus({
initial_password: "",
initial_password_revealed_at: "2026-04-21T00:00:00Z",
onboarding: {
...readyStatus().onboarding,
completed_steps: ["vaultwarden_master_password"],
vaultwarden: { matched: true, recovery_email: "" },
},
}));
return jsonResponse({});
});
const { flow, wrapper } = mountFlow();
flow.loading.value = true;
await flow.check();
expect(fetch).not.toHaveBeenCalledWith("/api/access/request/status", expect.anything());
flow.loading.value = false;
await flow.setStepCompletion("vaultwarden_master_password", true);
expect(flow.error.value).toBe("Request code is missing.");
await flow.retryProvisioning();
expect(flow.retryMessage.value).toBe("");
flow.requestCode.value = "ada~abc";
await flow.check();
expect(flow.passwordRevealLocked.value).toBe(true);
expect(flow.passwordRevealHint.value).toContain("already revealed");
expect(flow.vaultwardenLoginEmailLower.value).toBe("your recovery email");
flow.onboarding.value = {
required_steps: ["vaultwarden_master_password", "keycloak_password_rotated", "manual_required"],
optional_steps: ["optional_step"],
completed_steps: [],
vaultwarden: { matched: false },
};
flow.requestUsername.value = "";
expect(flow.vaultwardenLoginEmail.value).toBe("your @bstein.dev address");
flow.requestUsername.value = "Ada";
expect(flow.vaultwardenLoginEmailLower.value).toBe("ada@bstein.dev");
expect(flow.stepNote({ id: "vaultwarden_master_password" })).toContain("ada@bstein.dev");
expect(flow.stepNote({ id: "vaultwarden_store_temp_password" })).toContain("temporary Keycloak");
expect(flow.stepNote({ id: "mail_client_setup" })).toContain("ada@bstein.dev");
expect(flow.stepNote({ id: "unknown" })).toBe("");
expect(flow.stepPillLabel({ id: "element_recovery_key", action: "confirm" })).toBe("blocked");
expect(flow.stepPillLabel({ id: "auto_step", action: "auto" })).toBe("pending");
expect(flow.stepPillLabel({ id: "optional_step", action: "checkbox" })).toBe("optional");
expect(flow.stepPillLabel({ id: "manual_required", action: "checkbox" })).toBe("pending");
expect(flow.stepPillClass({ id: "element_recovery_key" })).toBe("pill-wait");
flow.onboarding.value.completed_steps = ["vaultwarden_master_password"];
expect(flow.stepPillClass({ id: "keycloak_password_rotated" })).toBe("pill-info");
flow.keycloakPasswordRotationRequested.value = true;
expect(flow.stepPillLabel({ id: "keycloak_password_rotated", action: "confirm" })).toBe("rotate now");
expect(flow.stepPillClass({ id: "keycloak_password_rotated" })).toBe("pill-warn");
flow.confirmingStepId.value = "manual_required";
expect(flow.isConfirming({ id: "manual_required" })).toBe(true);
expect(flow.confirmLabel({ id: "manual_required" })).toBe("Confirming...");
flow.confirmingStepId.value = "";
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: jest.fn(async () => { throw new Error("copy denied"); }) },
});
await flow.copyText("secret", () => {});
expect(flow.error.value).toBe("copy denied");
await flow.copyText("", () => { throw new Error("should not run"); });
await expect(flow.runRotationCheck("firefly")).rejects.toThrow("Log in");
auth.authenticated = true;
auth.token = "token";
await expect(flow.runRotationCheck("wger")).resolves.toEqual({ rotated: true });
global.fetch.mockResolvedValueOnce(jsonResponse({ error: "rotation down" }, 500, "Server Error"));
await expect(flow.runRotationCheck("wger")).rejects.toThrow("rotation down");
flow.onboarding.value.completed_steps = ["vaultwarden_master_password"];
flow.keycloakPasswordRotationRequested.value = false;
await flow.requestKeycloakPasswordRotation();
expect(flow.keycloakPasswordRotationRequested.value).toBe(true);
await flow.requestKeycloakPasswordRotation();
expect(responses.rotateCalls).toBe(2);
await flow.confirmStep(null);
await flow.confirmStep({ id: "vaultwarden_master_password", action: "confirm" });
await flow.confirmStep({ id: "keycloak_password_rotated", action: "confirm" });
flow.onboarding.value.completed_steps = ["vaultwarden_master_password", "keycloak_password_rotated", "element_recovery_key", "firefly_password_rotated"];
await flow.confirmStep({ id: "wger_password_rotated", action: "auto" });
await flow.confirmStep({ id: "mail_client_setup", action: "confirm" });
await flow.confirmStep({ id: "jellyfin_web_access", action: "checkbox" });
await flow.toggleStep("mail_client_setup", {});
flow.requestCode.value = "";
await flow.requestKeycloakPasswordRotation();
expect(flow.error.value).toBe("Request code is missing.");
flow.requestCode.value = "ada~abc";
flow.onboarding.value.completed_steps = [];
flow.keycloakPasswordRotationRequested.value = false;
await flow.requestKeycloakPasswordRotation();
expect(flow.error.value).toBe("Complete earlier onboarding steps first.");
wrapper.unmount();
});
it("renders onboarding page states and guide interactions", async () => {
mockRoute = { query: { code: "ada~abc" } };
installFetch((url) => {
if (url.includes("/media/onboarding")) return manifestResponse();
if (url.includes("/status")) return jsonResponse(readyStatus());
if (url.includes("/onboarding/attest")) return jsonResponse(readyStatus({
onboarding: { ...readyStatus().onboarding, completed_steps: ["vaultwarden_master_password", "vaultwarden_browser_extension"] },
}));
return jsonResponse({});
});
const wrapper = mount(OnboardingView);
await flushPromises();
expect(wrapper.text()).toContain("Onboarding");
expect(wrapper.text()).toContain("Keycloak temporary credentials");
expect(wrapper.find("input[readonly]").element.value).toBe("ada");
expect(wrapper.text()).toContain("Photo guide");
const revealButton = wrapper.findAll("button.secondary").find((button) => button.text() === "Reveal");
await revealButton.trigger("click");
await nextTick();
expect(wrapper.text()).toContain("Hide");
const checkbox = wrapper.find("input[type='checkbox']");
if (checkbox.exists()) {
await checkbox.setValue(true);
await flushPromises();
}
const img = wrapper.find(".guide-shot");
if (img.exists()) {
await img.trigger("click");
expect(wrapper.find(".lightbox").exists()).toBe(true);
await wrapper.find(".lightbox button").trigger("click");
expect(wrapper.find(".lightbox").exists()).toBe(false);
}
wrapper.unmount();
});
it("renders non-onboarding statuses and copy fallback", async () => {
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
Object.defineProperty(document, "execCommand", { configurable: true, value: jest.fn(() => true) });
installFetch((url) => {
if (url.includes("/media/onboarding")) return manifestResponse();
if (url.includes("/status")) return jsonResponse(readyStatus({
status: "accounts_building",
blocked: true,
tasks: [{ task: "mail", status: "error", detail: "smtp" }],
}));
if (url.includes("/retry")) return jsonResponse({ error: "retry failed" }, 500, "Server Error");
return jsonResponse({});
});
const { flow, wrapper } = mountFlow({ query: { request_code: "ada~abc" } });
await flushPromises();
expect(flow.blocked.value).toBe(true);
await flow.copyText("manual", () => {});
expect(document.execCommand).toHaveBeenCalledWith("copy");
await flow.retryProvisioning();
expect(flow.retryMessage.value).toBe("retry failed");
global.fetch.mockResolvedValueOnce(jsonResponse({ error: "status failed" }, 500, "Server Error"));
await flow.check();
expect(flow.error.value).toBe("status failed");
wrapper.unmount();
});
});