diff --git a/testing/frontend/unit/onboarding-flow.spec.js b/testing/frontend/unit/onboarding-flow.spec.js new file mode 100644 index 0000000..06380bf --- /dev/null +++ b/testing/frontend/unit/onboarding-flow.spec.js @@ -0,0 +1,475 @@ +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(); + }); +});