diff --git a/src/lib/admin-helpers.test.ts b/src/lib/admin-helpers.test.ts new file mode 100644 index 00000000..d718e6d9 --- /dev/null +++ b/src/lib/admin-helpers.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + calcPagination, + PER_PAGE_OPTIONS, + parseListParams, +} from "./admin-helpers"; + +describe("parseListParams", () => { + it("parses defaults", () => { + const p = parseListParams(new URLSearchParams()); + expect(p).toEqual({ + search: "", + perPage: 20, + page: 1, + sort: undefined, + order: "desc", + }); + }); + + it("parses explicit values", () => { + const p = parseListParams( + new URLSearchParams("search=sofa&perPage=50&page=3&sort=name&order=asc"), + ); + expect(p.search).toBe("sofa"); + expect(p.perPage).toBe(50); + expect(p.page).toBe(3); + expect(p.sort).toBe("name"); + expect(p.order).toBe("asc"); + }); + + it("clamps perPage to [1, 100]", () => { + expect(parseListParams(new URLSearchParams("perPage=0")).perPage).toBe(1); + expect(parseListParams(new URLSearchParams("perPage=1000")).perPage).toBe( + 100, + ); + }); + + it("clamps page to >= 1", () => { + expect(parseListParams(new URLSearchParams("page=0")).page).toBe(1); + expect(parseListParams(new URLSearchParams("page=-5")).page).toBe(1); + }); + + it("defaults order to desc when not asc", () => { + expect(parseListParams(new URLSearchParams("order=asc")).order).toBe("asc"); + expect(parseListParams(new URLSearchParams("order=desc")).order).toBe( + "desc", + ); + expect(parseListParams(new URLSearchParams("order=bogus")).order).toBe( + "desc", + ); + }); +}); + +describe("calcPagination", () => { + it("computes last page, offset and clamps the page", () => { + const p = calcPagination(50, 3, 20); + expect(p.lastPage).toBe(3); + expect(p.page).toBe(3); + expect(p.offset).toBe(40); + }); + + it("clamps page beyond last page", () => { + const p = calcPagination(10, 99, 20); + expect(p.lastPage).toBe(1); + expect(p.page).toBe(1); + expect(p.offset).toBe(0); + }); + + it("handles zero total with lastPage 1", () => { + const p = calcPagination(0, 1, 20); + expect(p.lastPage).toBe(1); + expect(p.offset).toBe(0); + }); +}); + +describe("PER_PAGE_OPTIONS", () => { + it("exposes the selectable page sizes", () => { + expect(PER_PAGE_OPTIONS).toEqual([10, 20, 50]); + }); +}); diff --git a/src/lib/admin-nav.test.ts b/src/lib/admin-nav.test.ts new file mode 100644 index 00000000..939c9b95 --- /dev/null +++ b/src/lib/admin-nav.test.ts @@ -0,0 +1,93 @@ +import { Calendar } from "lucide-react"; +import { describe, expect, it } from "vitest"; +import { + ADMIN_NAV_GROUPS, + collectNavPermissionSlugs, + findAdminHub, + navItemIsAllowed, +} from "./admin-nav"; + +describe("findAdminHub", () => { + it("matches by prefix", () => { + expect(findAdminHub("/admin/events")?.id).toBe("events"); + expect(findAdminHub("/admin/events/123")?.id).toBe("events"); + }); + it("uses longest-prefix match", () => { + expect(findAdminHub("/admin/tickets/desk")?.id).toBe("tickets"); + }); + it("returns null for non-hub paths", () => { + expect(findAdminHub("/admin/users")).toBeNull(); + expect(findAdminHub("/login")).toBeNull(); + }); +}); + +describe("navItemIsAllowed", () => { + const item = { + href: "/admin/x", + labelKey: "x", + icon: Calendar, + permission: "a.view", + }; + + it("always allows for super admins", () => { + expect( + navItemIsAllowed(item, { isSuperAdmin: true, has: () => false }), + ).toBe(true); + }); + it("allows when any needed slug is granted", () => { + expect( + navItemIsAllowed(item, { + isSuperAdmin: false, + has: (s) => s === "a.view", + }), + ).toBe(true); + }); + it("denies when no slug is granted", () => { + expect( + navItemIsAllowed(item, { isSuperAdmin: false, has: () => false }), + ).toBe(false); + }); + it("allows items without a permission requirement", () => { + expect( + navItemIsAllowed( + { href: "/admin/d", labelKey: "d", icon: Calendar }, + { isSuperAdmin: false, has: () => false }, + ), + ).toBe(true); + }); + it("supports an array of permissions (any match)", () => { + const multi = { + href: "/admin/m", + labelKey: "m", + icon: Calendar, + permission: ["x.view", "y.view"], + }; + expect( + navItemIsAllowed(multi, { + isSuperAdmin: false, + has: (s) => s === "y.view", + }), + ).toBe(true); + }); +}); + +describe("collectNavPermissionSlugs", () => { + it("returns unique slugs referenced by the sidebar", () => { + const slugs = collectNavPermissionSlugs(); + expect(new Set(slugs).size).toBe(slugs.length); + expect(slugs.length).toBeGreaterThan(0); + }); + it("is consistent with the nav groups", () => { + const groupSlugs = new Set(); + for (const group of ADMIN_NAV_GROUPS) { + for (const item of group.items) { + if (!item.permission) continue; + const needed = Array.isArray(item.permission) + ? item.permission + : [item.permission]; + for (const s of needed) groupSlugs.add(s); + } + } + expect([...groupSlugs]).toEqual(collectNavPermissionSlugs()); + }); +}); diff --git a/src/lib/admin/authorization-policy.test.ts b/src/lib/admin/authorization-policy.test.ts index e49db725..38337766 100644 --- a/src/lib/admin/authorization-policy.test.ts +++ b/src/lib/admin/authorization-policy.test.ts @@ -2,58 +2,69 @@ import { describe, expect, it } from "vitest"; import { decideAuthorization, isDynamicSuperAdmin, -} from "@/lib/admin/authorization-policy"; +} from "./authorization-policy"; describe("isDynamicSuperAdmin", () => { - it.each([ - [7, 7], - [11, 11], - [2000, 2000], - ])("accepts highest rank %i", (rank, highest) => { - expect(isDynamicSuperAdmin(rank, highest)).toBe(true); + it("returns true when rank equals the highest rank", () => { + expect(isDynamicSuperAdmin(7, 7)).toBe(true); + }); + it("returns false for non-highest ranks", () => { + expect(isDynamicSuperAdmin(6, 7)).toBe(false); + }); + it("returns false when highestRank is null or invalid", () => { + expect(isDynamicSuperAdmin(7, null)).toBe(false); + expect(isDynamicSuperAdmin(0, 7)).toBe(false); + expect(isDynamicSuperAdmin(-1, 7)).toBe(false); + expect(isDynamicSuperAdmin(1.5, 1.5)).toBe(false); }); - it("demotes the previous highest rank", () => - expect(isDynamicSuperAdmin(2000, 2001)).toBe(false)); - it("fails closed without ranks", () => - expect(isDynamicSuperAdmin(1, null)).toBe(false)); }); describe("decideAuthorization", () => { - const actor = { id: 1, username: "admin", rank: 11 }; - it("allows the dynamically highest rank", () => - expect( - decideAuthorization({ - actor, - highestRank: 11, - permission: "admin.any", - hasPermission: false, - }).allowed, - ).toBe(true)); - it("allows explicit ACL permission below highest", () => - expect( - decideAuthorization({ - actor, - highestRank: 12, - permission: "admin.news.view", - hasPermission: true, - }).allowed, - ).toBe(true)); - it("denies invalid ranks", () => + const actor = { id: 1, username: "staff", rank: 5 }; + const base = { actor, highestRank: 7, hasPermission: false }; + + it("allows super admins regardless of permission", () => { + const decision = decideAuthorization({ + ...base, + actor: { ...actor, rank: 7 }, + hasPermission: false, + }); + expect(decision).toEqual({ allowed: true, superAdmin: true }); + }); + + it("denies invalid ranks", () => { expect( decideAuthorization({ + ...base, actor: { ...actor, rank: 0 }, - highestRank: 11, - permission: "admin.any", - hasPermission: true, }), - ).toMatchObject({ allowed: false, reason: "invalid_rank" })); - it("denies missing permission", () => - expect( - decideAuthorization({ - actor, - highestRank: 12, - permission: "admin.any", - hasPermission: false, - }), - ).toMatchObject({ allowed: false, reason: "permission_denied" })); + ).toEqual({ allowed: false, reason: "invalid_rank" }); + }); + + it("denies when there are no ranks configured", () => { + expect(decideAuthorization({ ...base, highestRank: null })).toEqual({ + allowed: false, + reason: "no_ranks", + }); + }); + + it("allows when permission is granted", () => { + expect(decideAuthorization({ ...base, hasPermission: true })).toEqual({ + allowed: true, + superAdmin: false, + }); + }); + + it("allows when no permission is required", () => { + const decision = decideAuthorization({ ...base, permission: undefined }); + expect(decision).toEqual({ allowed: true, superAdmin: false }); + }); + + it("denies when permission is missing", () => { + const decision = decideAuthorization({ + ...base, + permission: "users.manage", + }); + expect(decision).toEqual({ allowed: false, reason: "permission_denied" }); + }); }); diff --git a/src/lib/admin/log-filters.test.ts b/src/lib/admin/log-filters.test.ts index 60ed484d..d5126df2 100644 --- a/src/lib/admin/log-filters.test.ts +++ b/src/lib/admin/log-filters.test.ts @@ -1,22 +1,39 @@ import { describe, expect, it } from "vitest"; -import { buildStaffActivityWhere } from "@/lib/admin/log-filters"; +import { buildStaffActivityWhere } from "./log-filters"; describe("buildStaffActivityWhere", () => { - it("filters authorization events by prefix", () => { + it("returns an empty where when no filters are set", () => { + expect(buildStaffActivityWhere({})).toEqual({}); + }); + + it("builds an OR search across action, description and IP", () => { + const where = buildStaffActivityWhere({ q: "ban " }); + expect(where.OR).toEqual([ + { action: { contains: "ban" } }, + { description: { contains: "ban" } }, + { ipAddress: { contains: "ban" } }, + ]); + expect(where.OR?.[0]).toEqual({ action: { contains: "ban" } }); + }); + + it("sets userId when staffId is provided", () => { + const where = buildStaffActivityWhere({ staffId: 42 }); + expect(where.userId).toBe(BigInt(42)); + }); + + it("uses a prefix match for authorization-only", () => { expect(buildStaffActivityWhere({ authorizationOnly: true })).toEqual({ action: { startsWith: "permission." }, }); }); - it("combines staff and search filters", () => { - const result = buildStaffActivityWhere({ - q: "rank", - staffId: 11, - authorizationOnly: true, + + it("uses a contains match for a specific action", () => { + expect(buildStaffActivityWhere({ action: "ban" })).toEqual({ + action: { contains: "ban" }, }); - expect(result).toMatchObject({ - userId: 11n, - action: { startsWith: "permission." }, - }); - expect(result.OR).toHaveLength(3); + }); + + it("ignores whitespace-only search terms", () => { + expect(buildStaffActivityWhere({ q: " " })).toEqual({}); }); }); diff --git a/src/lib/admin/notice.test.ts b/src/lib/admin/notice.test.ts index cd749b13..a4954be3 100644 --- a/src/lib/admin/notice.test.ts +++ b/src/lib/admin/notice.test.ts @@ -1,24 +1,26 @@ import { describe, expect, it } from "vitest"; -import { adminMutationNotice } from "@/lib/admin/notice"; +import { adminMutationNotice } from "./notice"; describe("adminMutationNotice", () => { - it("maps a successful redirect to a safe notice", () => { + it("returns a danger notice when there is an error", () => { + expect(adminMutationNotice({ error: "boom" })).toEqual({ + tone: "danger", + label: "Error", + message: "The operation could not be completed.", + }); + }); + it("returns a success notice when saved is 1", () => { expect(adminMutationNotice({ saved: "1" })).toEqual({ tone: "ok", label: "Saved", message: "Changes were saved successfully.", }); }); - - it("maps an error code without reflecting arbitrary query text", () => { - expect(adminMutationNotice({ error: "