// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { createStore } from "vuex";
import { createRouter, createWebHistory } from "vue-router";
import { nextTick } from "vue";
import AddCipherKey from "@/components/cipherkeys/AddCipherKey.vue";
import CipherKeysService from "@/services/cipher_keys";
import i18n from "@/locales";
// The cipherkeys family is migrated to a SINGLE parent OForm + Zod
// (AddCipherKey.schema.ts): the children render OForm* controls connected to
// this form by name. These tests mount the REAL OForm (never stub it) so they
// exercise the actual schema wiring — an empty required field must block submit
// and NOT call the service (the gap that let the AddToDashboard bug ship).
vi.mock("@/services/cipher_keys", async (importOriginal) => {
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
return overlayServiceMock(await importOriginal(), {
default: {
create: vi.fn(),
update: vi.fn(),
get_by_name: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
},
});
});
// toast returns a dismiss() fn — keep it a no-op so the loading toast teardown
// in the save handlers doesn't blow up.
vi.mock("@/lib/feedback/Toast/useToast", () => ({
toast: vi.fn(() => vi.fn()),
}));
const flush = () => flushPromises();
const setField = (wrapper: any, name: string, value: unknown) =>
wrapper.vm.form.setFieldValue(name, value);
// Drive a real submit (runs the schema + awaits the @submit handler).
const submit = async (wrapper: any) => {
await wrapper.vm.form.handleSubmit();
await flush();
};
const fillValidLocal = (wrapper: any) => {
setField(wrapper, "name", "my-cipher-key");
setField(wrapper, "key.store.type", "local");
setField(wrapper, "key.store.local", "super-secret");
};
describe("AddCipherKey.vue", () => {
let wrapper: any;
let store: any;
let router: any;
const createWrapper = async (routeQuery: Record = {}) => {
store = createStore({
state: { selectedOrganization: { identifier: "test-org" } },
});
router = createRouter({
history: createWebHistory(),
routes: [{ path: "/", component: { template: "" } }],
});
router.push({ query: routeQuery });
await router.isReady();
const w = mount(AddCipherKey, {
global: { plugins: [store, router, i18n] },
});
await flush();
return w;
};
beforeEach(() => {
vi.clearAllMocks();
(CipherKeysService.create as any).mockResolvedValue({ data: {} });
(CipherKeysService.update as any).mockResolvedValue({ data: {} });
});
afterEach(() => {
if (wrapper) wrapper.unmount();
});
describe("rendering", () => {
it("mounts with the real OForm", async () => {
wrapper = await createWrapper();
expect(wrapper.exists()).toBe(true);
expect(wrapper.findComponent({ name: "OForm" }).exists()).toBe(true);
});
it("shows the create (not update) title in create mode", async () => {
wrapper = await createWrapper();
expect(wrapper.find('[data-test="add-template-title"]').text()).not.toContain("Update");
});
it("renders the name input, type select, and action buttons", async () => {
wrapper = await createWrapper();
expect(wrapper.find('[data-test="add-cipher-key-name-input"]').exists()).toBe(true);
expect(wrapper.find('[data-test="add-cipher-key-type-input"]').exists()).toBe(true);
expect(wrapper.find('[data-test="add-cipher-key-cancel-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-test="add-cipher-key-save-btn"]').exists()).toBe(true);
});
it("starts at step 1", async () => {
wrapper = await createWrapper();
expect(wrapper.vm.step).toBe(1);
});
// Pre-migration this button was step-gated (`step === 1 && !isUpdating`), so
// create-mode Save was dead on step 1. R3 drops that: Save always submits and
// the schema decides. The two assertions below are the whole contract — it is
// only safe to leave Save enabled BECAUSE an invalid submit reports why.
it("keeps the Save button enabled and submitting (R3 — never disabled)", async () => {
wrapper = await createWrapper();
const saveBtn = wrapper.find('[data-test="add-cipher-key-save-btn"]');
expect(saveBtn.attributes("disabled")).toBeUndefined();
expect(saveBtn.attributes("type")).toBe("submit");
});
it("surfaces the name error when Save submits an empty form on step 1", async () => {
wrapper = await createWrapper();
expect(wrapper.vm.step).toBe(1);
await wrapper.find("form").trigger("submit");
await flush();
expect(CipherKeysService.create).not.toHaveBeenCalled();
expect(wrapper.find('[data-test="add-cipher-key-name-input-error"]').text()).toBe(
"Name is required",
);
});
});
describe("validation gates the submit (real OForm)", () => {
it("blocks submit and does NOT create when required fields are empty", async () => {
wrapper = await createWrapper();
await submit(wrapper);
expect(wrapper.vm.form.state.isValid).toBe(false);
expect(CipherKeysService.create).not.toHaveBeenCalled();
});
it("creates a local key when the form is valid", async () => {
wrapper = await createWrapper();
fillValidLocal(wrapper);
await submit(wrapper);
expect(wrapper.vm.form.state.isValid).toBe(true);
expect(CipherKeysService.create).toHaveBeenCalledTimes(1);
const [org, payload] = (CipherKeysService.create as any).mock.calls[0];
expect(org).toBe("test-org");
expect(payload.name).toBe("my-cipher-key");
expect(payload.key.store.local).toBe("super-secret");
// Payload parity with the pre-migration code: the `isUpdate` UI flag is
// merged into the body (false on create). Backend ignores it.
expect(payload.isUpdate).toBe(false);
expect(wrapper.emitted("cancel:hideform")).toBeTruthy();
});
it("does NOT create when the local secret is missing", async () => {
wrapper = await createWrapper();
setField(wrapper, "name", "my-cipher-key");
setField(wrapper, "key.store.type", "local");
setField(wrapper, "key.store.local", "");
await submit(wrapper);
expect(CipherKeysService.create).not.toHaveBeenCalled();
});
it("creates an akeyless key when all conditional fields are valid", async () => {
wrapper = await createWrapper();
setField(wrapper, "name", "my-akeyless");
setField(wrapper, "key.store.type", "akeyless");
setField(wrapper, "key.store.akeyless.base_url", "https://api.akeyless.io");
setField(wrapper, "key.store.akeyless.access_id", "p-abc123");
setField(wrapper, "key.store.akeyless.auth.type", "access_key");
setField(wrapper, "key.store.akeyless.auth.access_key", "the-key");
setField(wrapper, "key.store.akeyless.store.type", "static_secret");
setField(wrapper, "key.store.akeyless.store.static_secret", "secret-name");
await submit(wrapper);
expect(CipherKeysService.create).toHaveBeenCalledTimes(1);
const [, payload] = (CipherKeysService.create as any).mock.calls[0];
expect(payload.key.store.type).toBe("akeyless");
expect(payload.key.store.akeyless.base_url).toBe("https://api.akeyless.io");
});
it("does NOT create an akeyless key with an invalid base URL", async () => {
wrapper = await createWrapper();
setField(wrapper, "name", "my-akeyless");
setField(wrapper, "key.store.type", "akeyless");
setField(wrapper, "key.store.akeyless.base_url", "not-a-url");
setField(wrapper, "key.store.akeyless.access_id", "p-abc123");
setField(wrapper, "key.store.akeyless.auth.type", "access_key");
setField(wrapper, "key.store.akeyless.auth.access_key", "the-key");
setField(wrapper, "key.store.akeyless.store.type", "static_secret");
setField(wrapper, "key.store.akeyless.store.static_secret", "secret-name");
await submit(wrapper);
expect(CipherKeysService.create).not.toHaveBeenCalled();
});
});
describe("stepper", () => {
it("Continue advances to step 2 only when the form is valid", async () => {
wrapper = await createWrapper();
fillValidLocal(wrapper);
await wrapper.vm.continueToStep2();
await nextTick();
expect(wrapper.vm.step).toBe(2);
// Advancing must not save.
expect(CipherKeysService.create).not.toHaveBeenCalled();
});
it("Continue stays on step 1 when the form is invalid", async () => {
wrapper = await createWrapper();
await wrapper.vm.continueToStep2();
await nextTick();
expect(wrapper.vm.step).toBe(1);
});
it("Back returns to step 1", async () => {
wrapper = await createWrapper();
fillValidLocal(wrapper);
await wrapper.vm.continueToStep2();
await nextTick();
expect(wrapper.vm.step).toBe(2);
await wrapper.find('[data-test="add-cipher-key-step2-back-btn"]').trigger("click");
expect(wrapper.vm.step).toBe(1);
});
it("renders the akeyless child when store.type is akeyless", async () => {
wrapper = await createWrapper();
setField(wrapper, "key.store.type", "akeyless");
await nextTick();
expect(wrapper.find('[data-test="add-cipher-key-akeyless-baseurl-input"]').exists()).toBe(
true,
);
});
it("renders the openobserve secret child when store.type is local", async () => {
wrapper = await createWrapper();
expect(wrapper.find('[data-test="add-cipher-key-openobserve-secret-input"]').exists()).toBe(
true,
);
});
});
describe("edit mode", () => {
const record = {
name: "existing-key",
key: {
store: { type: "local", local: "loaded-secret" },
mechanism: { type: "simple", simple_algorithm: "aes-256-siv" },
},
};
beforeEach(() => {
(CipherKeysService.get_by_name as any).mockResolvedValue({ data: record });
});
it("loads the record, flips to update mode, and prefills the form", async () => {
wrapper = await createWrapper({ action: "edit", name: "existing-key" });
await flush();
expect(wrapper.vm.isUpdatingCipherKey).toBe(true);
expect(wrapper.find('[data-test="add-template-title"]').text()).toContain("Update");
expect(wrapper.vm.form.state.values.name).toBe("existing-key");
expect(wrapper.vm.form.state.values.key.store.local).toBe("loaded-secret");
});
it("makes the name input readonly AND disabled in update mode", async () => {
wrapper = await createWrapper({ action: "edit", name: "existing-key" });
await flush();
const nameInput = wrapper.find('[data-test="add-cipher-key-name-input"] input');
// Pre-migration the name field carried BOTH readonly + disable in edit
// mode: readonly blocks typing, disabled gives the greyed "locked" look.
expect(nameInput.attributes("readonly")).toBeDefined();
expect(nameInput.attributes("disabled")).toBeDefined();
});
it("short-circuits with no update call when nothing changed", async () => {
wrapper = await createWrapper({ action: "edit", name: "existing-key" });
await flush();
await submit(wrapper);
expect(CipherKeysService.update).not.toHaveBeenCalled();
expect(wrapper.emitted("cancel:hideform")).toBeTruthy();
});
it("calls update when a field changed", async () => {
wrapper = await createWrapper({ action: "edit", name: "existing-key" });
await flush();
setField(wrapper, "key.store.local", "changed-secret");
await submit(wrapper);
expect(CipherKeysService.update).toHaveBeenCalledTimes(1);
const [org, payload, name] = (CipherKeysService.update as any).mock.calls[0];
expect(org).toBe("test-org");
expect(payload.key.store.local).toBe("changed-secret");
// Payload parity: `isUpdate` flag merged into the body (true on update).
expect(payload.isUpdate).toBe(true);
expect(name).toBe("existing-key");
});
});
describe("cancel", () => {
it("emits cancel:hideform directly when there are no changes", async () => {
wrapper = await createWrapper();
await wrapper.find('[data-test="add-cipher-key-cancel-btn"]').trigger("click");
await flush();
expect(wrapper.emitted("cancel:hideform")).toBeTruthy();
expect(document.body.querySelector('[data-test="editor-draft-leave-dialog"]')).toBeNull();
});
it("asks through the draft leave dialog when there are unsaved changes", async () => {
wrapper = await createWrapper();
window.dispatchEvent(new Event("pointerdown"));
setField(wrapper, "name", "dirty-name");
await nextTick();
await wrapper.find('[data-test="add-cipher-key-cancel-btn"]').trigger("click");
await flush();
expect(wrapper.emitted("cancel:hideform")).toBeFalsy();
expect(document.body.querySelector('[data-test="editor-draft-leave-dialog"]')).not.toBeNull();
});
});
});