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