test(bstein-home): cover account frontend
This commit is contained in:
parent
1348aa85a8
commit
3675302596
@ -302,16 +302,18 @@ export function useAccountDashboard() {
|
|||||||
const syncEnabled = Boolean(data.sync_enabled);
|
const syncEnabled = Boolean(data.sync_enabled);
|
||||||
const syncOk = Boolean(data.sync_ok);
|
const syncOk = Boolean(data.sync_ok);
|
||||||
const syncError = data.sync_error || "";
|
const syncError = data.sync_error || "";
|
||||||
|
let syncWarning = "";
|
||||||
if (!syncEnabled) {
|
if (!syncEnabled) {
|
||||||
mailu.status = "updated";
|
mailu.status = "updated";
|
||||||
mailu.error = "Mail sync is not configured; password may not take effect until an admin sync runs.";
|
syncWarning = "Mail sync is not configured; password may not take effect until an admin sync runs.";
|
||||||
} else if (!syncOk) {
|
} else if (!syncOk) {
|
||||||
mailu.status = "sync pending";
|
mailu.status = "sync pending";
|
||||||
mailu.error = syncError || "Mail sync did not confirm success yet. Try again in a moment.";
|
syncWarning = syncError || "Mail sync did not confirm success yet. Try again in a moment.";
|
||||||
} else {
|
} else {
|
||||||
mailu.status = "updated";
|
mailu.status = "updated";
|
||||||
}
|
}
|
||||||
await refreshOverview();
|
await refreshOverview();
|
||||||
|
if (syncWarning) mailu.error = syncWarning;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
mailu.error = formatActionError(err, "Rotation failed");
|
mailu.error = formatActionError(err, "Rotation failed");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
359
testing/frontend/unit/account-dashboard.spec.js
Normal file
359
testing/frontend/unit/account-dashboard.spec.js
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||||
|
import { flushPromises, mount } from "@vue/test-utils";
|
||||||
|
import { defineComponent, nextTick } from "vue";
|
||||||
|
|
||||||
|
import { auth } from "../../../frontend/src/auth.js";
|
||||||
|
import { useAccountDashboard } from "../../../frontend/src/account/useAccountDashboard.js";
|
||||||
|
import AccountView from "../../../frontend/src/views/AccountView.vue";
|
||||||
|
|
||||||
|
function jsonResponse(body, status = 200, statusText = "OK") {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
statusText,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function overview(overrides = {}) {
|
||||||
|
return {
|
||||||
|
mailu: { status: "ready", username: "ADA@BSTEIN.DEV", app_password: "mail-pass" },
|
||||||
|
nextcloud_mail: { status: "synced", primary_email: "ADA@BSTEIN.DEV", account_count: 1, synced_at: "now" },
|
||||||
|
wger: { status: "ready", username: "ADA@BSTEIN.DEV", password: "wger-pass", password_updated_at: "today" },
|
||||||
|
firefly: { status: "ready", username: "ADA@BSTEIN.DEV", password: "firefly-pass", password_updated_at: "today" },
|
||||||
|
vaultwarden: { status: "already_present", username: "ADA@BSTEIN.DEV", synced_at: "now" },
|
||||||
|
jellyfin: { status: "ready", username: "ada", sync_status: "ok", sync_detail: "LDAP synced" },
|
||||||
|
onboarding_url: "/onboarding?code=ada",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFetch(handler) {
|
||||||
|
global.fetch = jest.fn(async (url, options = {}) => handler(String(url), options));
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticate() {
|
||||||
|
auth.ready = true;
|
||||||
|
auth.enabled = true;
|
||||||
|
auth.authenticated = true;
|
||||||
|
auth.username = "ada";
|
||||||
|
auth.email = "Ada@Example.Dev";
|
||||||
|
auth.token = "token";
|
||||||
|
auth.accountPasswordUrl = "https://sso.example.dev/account/password";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountDashboard() {
|
||||||
|
let dashboard;
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
setup() {
|
||||||
|
dashboard = useAccountDashboard();
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
template: "<div />",
|
||||||
|
}));
|
||||||
|
return { dashboard, wrapper };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("account dashboard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
authenticate();
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText: jest.fn(async () => {}) },
|
||||||
|
});
|
||||||
|
installFetch((url) => {
|
||||||
|
if (url.includes("/api/account/overview")) return jsonResponse(overview());
|
||||||
|
if (url.includes("/api/admin/access/requests")) {
|
||||||
|
return jsonResponse({
|
||||||
|
requests: [{
|
||||||
|
username: "newuser",
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "User",
|
||||||
|
email: "new@example.dev",
|
||||||
|
request_code: "newuser~abc",
|
||||||
|
note: "please",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: ["media", "budget"] });
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
Object.assign(auth, {
|
||||||
|
ready: false,
|
||||||
|
enabled: false,
|
||||||
|
authenticated: false,
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
token: "",
|
||||||
|
accountPasswordUrl: "",
|
||||||
|
});
|
||||||
|
Reflect.deleteProperty(global, "fetch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders login-required state when unauthenticated", async () => {
|
||||||
|
auth.ready = true;
|
||||||
|
auth.authenticated = false;
|
||||||
|
const { dashboard, wrapper } = mountDashboard();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(dashboard.mailu.status).toBe("login required");
|
||||||
|
expect(dashboard.vaultwardenDisplayStatus.value).toBe("login required");
|
||||||
|
expect(dashboard.admin.enabled).toBe(false);
|
||||||
|
wrapper.unmount();
|
||||||
|
|
||||||
|
const view = mount(AccountView);
|
||||||
|
await nextTick();
|
||||||
|
expect(view.text()).toContain("Login required");
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reacts when auth readiness changes after mount", async () => {
|
||||||
|
auth.ready = false;
|
||||||
|
auth.authenticated = false;
|
||||||
|
const { dashboard, wrapper } = mountDashboard();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
auth.ready = true;
|
||||||
|
await nextTick();
|
||||||
|
expect(dashboard.mailu.status).toBe("login required");
|
||||||
|
expect(dashboard.admin.requests).toEqual([]);
|
||||||
|
|
||||||
|
auth.authenticated = true;
|
||||||
|
auth.username = "ada";
|
||||||
|
auth.email = "ada@example.dev";
|
||||||
|
auth.token = "token";
|
||||||
|
await flushPromises();
|
||||||
|
expect(dashboard.mailu.username).toBe("ada@bstein.dev");
|
||||||
|
expect(dashboard.admin.enabled).toBe(true);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads overview, admin requests, and renders the authenticated view", async () => {
|
||||||
|
const { dashboard, wrapper } = mountDashboard();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(dashboard.mailu.username).toBe("ada@bstein.dev");
|
||||||
|
expect(dashboard.mailu.currentPassword).toBe("mail-pass");
|
||||||
|
expect(dashboard.wger.password).toBe("wger-pass");
|
||||||
|
expect(dashboard.firefly.password).toBe("firefly-pass");
|
||||||
|
expect(dashboard.vaultwardenReady.value).toBe(true);
|
||||||
|
expect(dashboard.vaultwardenOrder.value).toBe(3);
|
||||||
|
expect(dashboard.jellyfin.syncStatus).toBe("ok");
|
||||||
|
expect(dashboard.onboardingUrl.value).toBe("/onboarding?code=ada");
|
||||||
|
expect(dashboard.admin.enabled).toBe(true);
|
||||||
|
expect(dashboard.admin.flags).toEqual(["media", "budget"]);
|
||||||
|
expect(dashboard.hasFlag("newuser", "media")).toBe(false);
|
||||||
|
expect(dashboard.formatName(dashboard.admin.requests[0])).toBe("New User");
|
||||||
|
expect(dashboard.formatName({ first_name: " ", last_name: "" })).toBe("unknown");
|
||||||
|
expect(dashboard.formatName()).toBe("unknown");
|
||||||
|
|
||||||
|
dashboard.toggleFlag("newuser", "media", { target: { checked: true } });
|
||||||
|
dashboard.toggleFlag("newuser", "budget", { target: { checked: true } });
|
||||||
|
dashboard.toggleFlag("newuser", "media", { target: { checked: false } });
|
||||||
|
expect(dashboard.admin.selectedFlags.newuser).toEqual(["budget"]);
|
||||||
|
wrapper.unmount();
|
||||||
|
|
||||||
|
const view = mount(AccountView);
|
||||||
|
await flushPromises();
|
||||||
|
expect(view.text()).toContain("Firefly III");
|
||||||
|
expect(view.text()).toContain("Admin Approvals");
|
||||||
|
expect(view.text()).toContain("newuser");
|
||||||
|
expect(view.find("a[href='https://sso.example.dev/account/password']").exists()).toBe(true);
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rotates service credentials and syncs account services", async () => {
|
||||||
|
installFetch((url) => {
|
||||||
|
if (url.includes("/mailu/rotate")) return jsonResponse({ password: "new-mail", sync_enabled: true, sync_ok: true });
|
||||||
|
if (url.includes("/wger/reset")) return jsonResponse({ password: "new-wger" });
|
||||||
|
if (url.includes("/firefly/reset")) return jsonResponse({ password: "new-firefly" });
|
||||||
|
if (url.includes("/nextcloud/mail/sync")) return jsonResponse({ ok: true });
|
||||||
|
if (url.includes("/api/account/overview")) return jsonResponse(overview());
|
||||||
|
if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] });
|
||||||
|
if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: [] });
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
const { dashboard, wrapper } = mountDashboard();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await dashboard.rotateMailu();
|
||||||
|
expect(dashboard.mailu.currentPassword).toBe("mail-pass");
|
||||||
|
expect(dashboard.mailu.rotating).toBe(false);
|
||||||
|
|
||||||
|
await dashboard.resetWger();
|
||||||
|
expect(dashboard.wger.password).toBe("wger-pass");
|
||||||
|
expect(dashboard.wger.resetting).toBe(false);
|
||||||
|
|
||||||
|
await dashboard.resetFirefly();
|
||||||
|
expect(dashboard.firefly.password).toBe("firefly-pass");
|
||||||
|
expect(dashboard.firefly.resetting).toBe(false);
|
||||||
|
|
||||||
|
await dashboard.syncNextcloudMail();
|
||||||
|
expect(dashboard.nextcloudMail.syncing).toBe(false);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles service action warnings and failures", async () => {
|
||||||
|
const rotateResponses = [
|
||||||
|
{ password: "mail-a", sync_enabled: false },
|
||||||
|
{ password: "mail-b", sync_enabled: true, sync_ok: false, sync_error: "sync slow" },
|
||||||
|
{ error: "ariadne unavailable" },
|
||||||
|
];
|
||||||
|
installFetch((url) => {
|
||||||
|
if (url.includes("/mailu/rotate")) {
|
||||||
|
const body = rotateResponses.shift();
|
||||||
|
return body.error ? jsonResponse(body, 503, "Service Unavailable") : jsonResponse(body);
|
||||||
|
}
|
||||||
|
if (url.includes("/wger/reset")) return jsonResponse({ error: "status 502" }, 502, "Bad Gateway");
|
||||||
|
if (url.includes("/firefly/reset")) return jsonResponse({}, 500, "Server Error");
|
||||||
|
if (url.includes("/nextcloud/mail/sync")) return jsonResponse({ error: "status 503" }, 503, "Service Unavailable");
|
||||||
|
if (url.includes("/api/account/overview")) return jsonResponse(overview());
|
||||||
|
if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] });
|
||||||
|
if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: [] });
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
const { dashboard, wrapper } = mountDashboard();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await dashboard.rotateMailu();
|
||||||
|
expect(dashboard.mailu.error).toContain("Mail sync is not configured");
|
||||||
|
await dashboard.rotateMailu();
|
||||||
|
expect(dashboard.mailu.error).toBe("sync slow");
|
||||||
|
await dashboard.rotateMailu();
|
||||||
|
expect(dashboard.mailu.error).toBe("Ariadne is busy. Please try again in a moment.");
|
||||||
|
|
||||||
|
await dashboard.resetWger();
|
||||||
|
expect(dashboard.wger.error).toBe("Ariadne is busy. Please try again in a moment.");
|
||||||
|
|
||||||
|
await dashboard.resetFirefly();
|
||||||
|
expect(dashboard.firefly.error).toBe("status 500");
|
||||||
|
|
||||||
|
await dashboard.syncNextcloudMail();
|
||||||
|
expect(dashboard.nextcloudMail.error).toContain("Refresh in a moment");
|
||||||
|
|
||||||
|
installFetch((url) => {
|
||||||
|
if (url.includes("/nextcloud/mail/sync")) return jsonResponse({ error: "plain sync failed" }, 500, "Server Error");
|
||||||
|
if (url.includes("/api/account/overview")) return jsonResponse(overview());
|
||||||
|
if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] });
|
||||||
|
if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: [] });
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
await dashboard.syncNextcloudMail();
|
||||||
|
expect(dashboard.nextcloudMail.error).toBe("plain sync failed");
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("approves, denies, and copies admin/account values", async () => {
|
||||||
|
const seen = [];
|
||||||
|
installFetch((url, options) => {
|
||||||
|
seen.push({ url, body: options.body });
|
||||||
|
if (url.includes("/approve") || url.includes("/deny")) return jsonResponse({ ok: true });
|
||||||
|
if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] });
|
||||||
|
if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: ["media"] });
|
||||||
|
if (url.includes("/api/account/overview")) return jsonResponse(overview());
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
const { dashboard, wrapper } = mountDashboard();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
dashboard.admin.selectedFlags.ada = ["media"];
|
||||||
|
dashboard.admin.notes.ada = " approve me ";
|
||||||
|
await dashboard.approve("ada");
|
||||||
|
expect(JSON.parse(seen.find((item) => item.url.includes("/approve")).body)).toEqual({ flags: ["media"], note: "approve me" });
|
||||||
|
|
||||||
|
dashboard.admin.notes.ada = " no ";
|
||||||
|
await dashboard.deny("ada");
|
||||||
|
expect(JSON.parse(seen.find((item) => item.url.includes("/deny")).body)).toEqual({ note: "no" });
|
||||||
|
|
||||||
|
await dashboard.copy("mail", "secret");
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("secret");
|
||||||
|
expect(dashboard.copied.mail).toBe(true);
|
||||||
|
jest.advanceTimersByTime(1500);
|
||||||
|
expect(dashboard.copied.mail).toBe(false);
|
||||||
|
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText: jest.fn(async () => { throw new Error("blocked"); }) },
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, "execCommand", { configurable: true, value: jest.fn(() => true) });
|
||||||
|
await dashboard.copy("fallback", "secret2");
|
||||||
|
expect(document.execCommand).toHaveBeenCalledWith("copy");
|
||||||
|
expect(dashboard.copied.fallback).toBe(true);
|
||||||
|
jest.advanceTimersByTime(1500);
|
||||||
|
expect(dashboard.copied.fallback).toBe(false);
|
||||||
|
|
||||||
|
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
|
||||||
|
await dashboard.copy("direct-fallback", "secret3");
|
||||||
|
expect(dashboard.copied["direct-fallback"]).toBe(true);
|
||||||
|
|
||||||
|
Object.defineProperty(document, "execCommand", { configurable: true, value: jest.fn(() => { throw new Error("no copy"); }) });
|
||||||
|
await dashboard.copy("ignored", "secret4");
|
||||||
|
expect(dashboard.copied.ignored).toBeUndefined();
|
||||||
|
|
||||||
|
await dashboard.copy("empty", "");
|
||||||
|
expect(dashboard.copied.empty).toBeUndefined();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces overview and admin loading failures", async () => {
|
||||||
|
installFetch((url) => {
|
||||||
|
if (url.includes("/api/account/overview")) return jsonResponse({ error: "overview down" }, 500, "Server Error");
|
||||||
|
if (url.includes("/api/admin/access/requests")) return jsonResponse({}, 403, "Forbidden");
|
||||||
|
if (url.includes("/api/admin/access/flags")) return jsonResponse({}, 500, "Server Error");
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
const { dashboard, wrapper } = mountDashboard();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(dashboard.mailu.status).toBe("unavailable");
|
||||||
|
expect(dashboard.mailu.error).toContain("overview down");
|
||||||
|
expect(dashboard.admin.enabled).toBe(false);
|
||||||
|
expect(dashboard.admin.flags).toEqual([]);
|
||||||
|
expect(dashboard.admin.error).toBe("status 500");
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles admin request errors and forbidden flag lists", async () => {
|
||||||
|
installFetch((url) => {
|
||||||
|
if (url.includes("/api/account/overview")) return jsonResponse(overview());
|
||||||
|
if (url.includes("/api/admin/access/requests")) return jsonResponse({}, 500, "Server Error");
|
||||||
|
if (url.includes("/api/admin/access/flags")) return jsonResponse({}, 403, "Forbidden");
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
const { dashboard, wrapper } = mountDashboard();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(dashboard.admin.enabled).toBe(false);
|
||||||
|
expect(dashboard.admin.requests).toEqual([]);
|
||||||
|
expect(dashboard.admin.flags).toEqual([]);
|
||||||
|
expect(dashboard.admin.error).toBe("status 500");
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports admin action failures without leaving acting locks stuck", async () => {
|
||||||
|
installFetch((url) => {
|
||||||
|
if (url.includes("/approve")) return jsonResponse({ error: "approve failed" }, 500, "Server Error");
|
||||||
|
if (url.includes("/deny")) return jsonResponse({}, 500, "Server Error");
|
||||||
|
if (url.includes("/api/account/overview")) return jsonResponse(overview());
|
||||||
|
if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] });
|
||||||
|
if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: [] });
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
const { dashboard, wrapper } = mountDashboard();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await dashboard.approve("ada");
|
||||||
|
expect(dashboard.admin.error).toBe("approve failed");
|
||||||
|
expect(dashboard.admin.acting.ada).toBe(false);
|
||||||
|
|
||||||
|
await dashboard.deny("ada");
|
||||||
|
expect(dashboard.admin.error).toBe("status 500");
|
||||||
|
expect(dashboard.admin.acting.ada).toBe(false);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user