yap-blog/tests/unit/permissions.test.ts
matt 35fb33c5a7 Add granular author permissions, admin-tag persistence, own-password change
Each author account now carries five grantable permissions, editable on
the Users page: publish posts, unpublish posts, delete posts (all three
scoped to the author's own posts), create tags, and approve comments
(scoped to comments on the author's posts, without delete). A bare
author writes and edits their own drafts only. Permission checks gate
the status TRANSITION, so editing an already-published post never
requires the publish permission, and the editor's status dropdown only
offers what the account may do. Tags an author creates are granted to
them automatically, and "creating" an existing off-grant tag is
refused (it would be a self-grant loophole). Existing author accounts
keep publish+unpublish via migration backfill.

Tags the admin attaches outside an author's grants now survive the
author's edits: the form shows them checked-and-locked and the server
re-attaches them on every save.

Every account can change its own password on the new /admin/account
page (current password required); the username in the admin header
links there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:58:19 -04:00

79 lines
2.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { Permissions } from "@/lib/auth/session";
import { resolveAuthorTagIds, statusChangeError } from "@/lib/permissions";
const perms = (overrides: Partial<Permissions> = {}): Permissions => ({
createTags: false,
publishPosts: false,
unpublishPosts: false,
deletePosts: false,
approveComments: false,
...overrides,
});
describe("statusChangeError", () => {
it("requires the publish permission to go draft -> published", () => {
expect(statusChangeError(perms(), null, "published")).toMatch(/publish/);
expect(statusChangeError(perms(), "draft", "published")).toMatch(/publish/);
expect(statusChangeError(perms({ publishPosts: true }), "draft", "published")).toBeNull();
});
it("requires the unpublish permission to go published -> draft", () => {
expect(statusChangeError(perms(), "published", "draft")).toMatch(/unpublish/);
expect(
statusChangeError(perms({ unpublishPosts: true }), "published", "draft"),
).toBeNull();
});
it("never blocks saves that keep the status", () => {
expect(statusChangeError(perms(), "draft", "draft")).toBeNull();
expect(statusChangeError(perms(), "published", "published")).toBeNull();
expect(statusChangeError(perms(), null, "draft")).toBeNull();
});
});
describe("resolveAuthorTagIds", () => {
const allowed = new Set([1, 2]);
it("rejects submissions outside the grants", () => {
const result = resolveAuthorTagIds({
submitted: [1, 3],
existing: [],
allowed,
creatingTags: false,
});
expect(result).toHaveProperty("error");
});
it("requires at least one granted tag unless creating one", () => {
expect(
resolveAuthorTagIds({ submitted: [], existing: [], allowed, creatingTags: false }),
).toHaveProperty("error");
expect(
resolveAuthorTagIds({ submitted: [], existing: [], allowed, creatingTags: true }),
).toEqual({ tagIds: [] });
});
it("preserves admin-added tags the author cannot see", () => {
// Post carries granted tag 1 and admin-added tag 9; the author's form
// resubmits only tag 2. Tag 9 must survive.
const result = resolveAuthorTagIds({
submitted: [2],
existing: [1, 9],
allowed,
creatingTags: false,
});
expect(result).toEqual({ tagIds: [2, 9] });
});
it("lets the author drop their own granted tags", () => {
const result = resolveAuthorTagIds({
submitted: [2],
existing: [1, 2],
allowed,
creatingTags: false,
});
expect(result).toEqual({ tagIds: [2] });
});
});