Pure logic layer for the top-right avatar role-switcher: derive the home a principal is in and may switch into, mirroring nav.ts/auth.ts so the decision is unit-tested without a React renderer. - ViewMode is derived from NavSection["id"], so the three switchable homes (User/Admin/SysAdmin) can never drift from the nav sections. - availableViewModes / effectiveViewMode resolve a requested view against the live is_admin flag, failing closed: a non-admin or a demoted admin always collapses to the User home. - restoreViewMode re-gates a persisted (localStorage) choice on every read, never trusting the stored value over the live flag, closing the one escalation vector a client-side persona could open. - sectionsForView composes the view ceiling on top of visibleSections, so the switcher only ever narrows the sidebar, never widens access. The avatar dropdown UI that consumes this lands as a separate increment.
165 lines
6.9 KiB
TypeScript
165 lines
6.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
VIEW_MODES,
|
|
availableViewModes,
|
|
effectiveViewMode,
|
|
parseViewMode,
|
|
restoreViewMode,
|
|
sectionsForView,
|
|
type ViewMode,
|
|
} from "./viewmode";
|
|
import { visibleSections } from "./nav";
|
|
|
|
// The role-switcher decides which home a principal is in and which they may switch
|
|
// into. One rule carries security weight and the rest is UX focus, so the cases
|
|
// below are split accordingly: the bulk pin the fail-closed rule from every angle a
|
|
// view can be chosen — a fresh request, a restored localStorage value, a tampered
|
|
// value — because the single thing that must never happen is a non-admin (or a
|
|
// demoted admin) landing in an Admin- or SysAdmin-Side view. The ceiling/section
|
|
// cases pin the navigational focus, which can declutter but never escalate.
|
|
|
|
describe("availableViewModes", () => {
|
|
it("offers a non-admin exactly the User-Side home", () => {
|
|
expect(availableViewModes(false)).toEqual(["user"]);
|
|
});
|
|
|
|
it("treats the fail-closed default (false) exactly like a non-admin", () => {
|
|
// TierProvider passes `false` while /me loads or after it rejects; the switcher
|
|
// must offer nothing but the user home in that window.
|
|
expect(availableViewModes(false)).toEqual(["user"]);
|
|
});
|
|
|
|
it("offers an admin every home, ordered least- to most-revealing", () => {
|
|
expect(availableViewModes(true)).toEqual(["user", "admin", "ops"]);
|
|
});
|
|
|
|
it("returns a fresh array so a caller cannot mutate the canonical list", () => {
|
|
const a = availableViewModes(true);
|
|
a.push("user");
|
|
expect(availableViewModes(true)).toEqual(["user", "admin", "ops"]);
|
|
});
|
|
});
|
|
|
|
describe("VIEW_MODES", () => {
|
|
it("is the three homes ordered by how much they reveal", () => {
|
|
expect(VIEW_MODES).toEqual(["user", "admin", "ops"]);
|
|
});
|
|
});
|
|
|
|
describe("effectiveViewMode (fail-closed resolution)", () => {
|
|
it("collapses any admin-level request from a non-admin to user", () => {
|
|
expect(effectiveViewMode("admin", false)).toBe("user");
|
|
expect(effectiveViewMode("ops", false)).toBe("user");
|
|
});
|
|
|
|
it("honours an admin's request for any home they are entitled to", () => {
|
|
expect(effectiveViewMode("user", true)).toBe("user");
|
|
expect(effectiveViewMode("admin", true)).toBe("admin");
|
|
expect(effectiveViewMode("ops", true)).toBe("ops");
|
|
});
|
|
|
|
it("defaults a null/undefined request to the user home for either tier", () => {
|
|
expect(effectiveViewMode(null, true)).toBe("user");
|
|
expect(effectiveViewMode(undefined, true)).toBe("user");
|
|
expect(effectiveViewMode(null, false)).toBe("user");
|
|
});
|
|
|
|
it("collapses a value outside the known homes to user, even for an admin", () => {
|
|
// Defends the runtime boundary: a value cast past the type system (a stale enum,
|
|
// a hand-edited store) is not in the allow-list, so it fails to the safe side.
|
|
expect(effectiveViewMode("root" as unknown as ViewMode, true)).toBe("user");
|
|
expect(effectiveViewMode("" as unknown as ViewMode, true)).toBe("user");
|
|
});
|
|
});
|
|
|
|
describe("parseViewMode (shape only, no gating)", () => {
|
|
it("accepts each known home verbatim", () => {
|
|
expect(parseViewMode("user")).toBe("user");
|
|
expect(parseViewMode("admin")).toBe("admin");
|
|
expect(parseViewMode("ops")).toBe("ops");
|
|
});
|
|
|
|
it("rejects anything that is not a known home, returning null", () => {
|
|
expect(parseViewMode("operator")).toBeNull();
|
|
expect(parseViewMode("Admin")).toBeNull(); // case-sensitive on purpose
|
|
expect(parseViewMode("")).toBeNull();
|
|
expect(parseViewMode(null)).toBeNull();
|
|
expect(parseViewMode(undefined)).toBeNull();
|
|
expect(parseViewMode(2)).toBeNull();
|
|
expect(parseViewMode({ mode: "ops" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("restoreViewMode (the persisted-value re-gate — escalation vector)", () => {
|
|
// This is the one place a client-persisted persona could escalate: a value lives
|
|
// in the user's own localStorage, fully under their control, and is read back on
|
|
// every load. The contract is that it is re-gated against the LIVE flag every
|
|
// time and never trusted on its own.
|
|
|
|
it("honours a stored admin/ops home only while the principal is still an admin", () => {
|
|
expect(restoreViewMode("ops", true)).toBe("ops");
|
|
expect(restoreViewMode("admin", true)).toBe("admin");
|
|
expect(restoreViewMode("user", true)).toBe("user");
|
|
});
|
|
|
|
it("collapses a stored admin/ops home to user for a non-admin (stale or tampered)", () => {
|
|
// A hand-edited localStorage "ops" on a non-admin account must NOT grant the
|
|
// SysAdmin view; and a once-admin who has since been demoted re-reads as user.
|
|
expect(restoreViewMode("ops", false)).toBe("user");
|
|
expect(restoreViewMode("admin", false)).toBe("user");
|
|
});
|
|
|
|
it("collapses to user while the admin flag is still fail-closed false (loading)", () => {
|
|
// Until /me resolves, isAdmin is false; a restored "ops" must wait at user, not
|
|
// flash the SysAdmin home and then yank it back.
|
|
expect(restoreViewMode("ops", false)).toBe("user");
|
|
});
|
|
|
|
it("collapses a malformed/garbage stored value to user even for an admin", () => {
|
|
expect(restoreViewMode("root", true)).toBe("user");
|
|
expect(restoreViewMode("", true)).toBe("user");
|
|
expect(restoreViewMode(null, true)).toBe("user");
|
|
expect(restoreViewMode(42, true)).toBe("user");
|
|
});
|
|
});
|
|
|
|
describe("sectionsForView (UX ceiling, composed on visibleSections)", () => {
|
|
it("shows a non-admin only the User-Side regardless of the requested view", () => {
|
|
for (const v of ["user", "admin", "ops"] as ViewMode[]) {
|
|
expect(sectionsForView(v, false).map((s) => s.id)).toEqual(["user"]);
|
|
}
|
|
});
|
|
|
|
it("foregrounds homes up to the chosen ceiling for an admin", () => {
|
|
expect(sectionsForView("user", true).map((s) => s.id)).toEqual(["user"]);
|
|
expect(sectionsForView("admin", true).map((s) => s.id)).toEqual(["user", "admin"]);
|
|
expect(sectionsForView("ops", true).map((s) => s.id)).toEqual([
|
|
"user",
|
|
"admin",
|
|
"ops",
|
|
]);
|
|
});
|
|
|
|
it("lets an admin step DOWN to the User-home and see only User-Side", () => {
|
|
// The new capability the switcher adds: an admin can choose to view the app as a
|
|
// plain user. visibleSections alone could never hide their admin nav; this can.
|
|
const ids = sectionsForView("user", true).map((s) => s.id);
|
|
expect(ids).toEqual(["user"]);
|
|
expect(ids).not.toContain("admin");
|
|
expect(ids).not.toContain("ops");
|
|
});
|
|
|
|
it("never returns more than visibleSections already permits (subset invariant)", () => {
|
|
// The switcher only ever narrows. For every (view, isAdmin) pair the result must
|
|
// be a subset of visibleSections(isAdmin) — it can never widen access.
|
|
for (const isAdmin of [true, false]) {
|
|
const permitted = new Set(visibleSections(isAdmin).map((s) => s.id));
|
|
for (const v of ["user", "admin", "ops"] as ViewMode[]) {
|
|
for (const s of sectionsForView(v, isAdmin)) {
|
|
expect(permitted.has(s.id)).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|