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>
219 lines
7.8 KiB
TypeScript
219 lines
7.8 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { db } from "@/db";
|
|
import { tags } from "@/db/schema";
|
|
import { verifyPassword } from "@/lib/auth/password";
|
|
import { buildSiteExport, importSiteExport } from "@/lib/services/import-export";
|
|
import { createPost, getPostById, listAllPosts, type PostInput } from "@/lib/services/posts";
|
|
import {
|
|
changeOwnPassword,
|
|
createUser,
|
|
deleteUser,
|
|
getAllowedTagIds,
|
|
getUserWithTags,
|
|
grantTags,
|
|
listUsersWithTags,
|
|
updateUser,
|
|
} from "@/lib/services/users";
|
|
import { resetDb } from "../helpers/db";
|
|
|
|
const postInput = (overrides: Partial<PostInput> = {}): PostInput => ({
|
|
title: `Post ${Math.random().toString(36).slice(2, 8)}`,
|
|
slug: "",
|
|
body: "body",
|
|
authorName: "Someone",
|
|
featuredImageUrl: null,
|
|
featuredImageAlt: null,
|
|
status: "published",
|
|
tagIds: [],
|
|
newTagNames: [],
|
|
...overrides,
|
|
});
|
|
|
|
async function seedTags(...names: string[]) {
|
|
return db
|
|
.insert(tags)
|
|
.values(names.map((name) => ({ name, slug: name.toLowerCase() })))
|
|
.returning();
|
|
}
|
|
|
|
async function seedAdmin() {
|
|
const [admin] = await db
|
|
.insert((await import("@/db/schema")).users)
|
|
.values({ username: "boss", passwordHash: "x", role: "admin" })
|
|
.returning();
|
|
return admin;
|
|
}
|
|
|
|
beforeEach(resetDb);
|
|
|
|
describe("account management", () => {
|
|
it("creates author accounts with tag grants", async () => {
|
|
const [design, writing] = await seedTags("Design", "Writing");
|
|
const user = await createUser({
|
|
username: "casey",
|
|
password: "hunter2hunter2",
|
|
tagIds: [design.id, writing.id],
|
|
});
|
|
expect(user.role).toBe("author");
|
|
expect((await getAllowedTagIds(user.id)).sort()).toEqual(
|
|
[design.id, writing.id].sort(),
|
|
);
|
|
|
|
const listed = await listUsersWithTags();
|
|
expect(listed.find((u) => u.username === "casey")?.tags.map((t) => t.name).sort()).toEqual(
|
|
["Design", "Writing"],
|
|
);
|
|
});
|
|
|
|
it("hashes passwords and updates them only when provided", async () => {
|
|
const user = await createUser({ username: "casey", password: "first-password", tagIds: [] });
|
|
const { users } = await import("@/db/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
const [row] = await db.select().from(users).where(eq(users.id, user.id));
|
|
expect(row.passwordHash).not.toContain("first-password");
|
|
expect(await verifyPassword(row.passwordHash, "first-password")).toBe(true);
|
|
|
|
await updateUser(user.id, { password: null, tagIds: [] });
|
|
const [same] = await db.select().from(users).where(eq(users.id, user.id));
|
|
expect(same.passwordHash).toBe(row.passwordHash);
|
|
|
|
await updateUser(user.id, { password: "second-password", tagIds: [] });
|
|
const [changed] = await db.select().from(users).where(eq(users.id, user.id));
|
|
expect(await verifyPassword(changed.passwordHash, "second-password")).toBe(true);
|
|
});
|
|
|
|
it("replaces tag grants on update", async () => {
|
|
const [design, writing] = await seedTags("Design", "Writing");
|
|
const user = await createUser({
|
|
username: "casey",
|
|
password: "hunter2hunter2",
|
|
tagIds: [design.id],
|
|
});
|
|
await updateUser(user.id, { password: null, tagIds: [writing.id] });
|
|
expect(await getAllowedTagIds(user.id)).toEqual([writing.id]);
|
|
});
|
|
|
|
it("stores permissions, defaults to none, and updates them", async () => {
|
|
const user = await createUser({
|
|
username: "casey",
|
|
password: "hunter2hunter2",
|
|
tagIds: [],
|
|
permissions: {
|
|
canCreateTags: false,
|
|
canPublishPosts: true,
|
|
canUnpublishPosts: false,
|
|
canDeletePosts: false,
|
|
canApproveComments: true,
|
|
},
|
|
});
|
|
expect(user.canPublishPosts).toBe(true);
|
|
expect(user.canApproveComments).toBe(true);
|
|
expect(user.canDeletePosts).toBe(false);
|
|
|
|
const bare = await createUser({ username: "dana", password: "hunter2hunter2", tagIds: [] });
|
|
expect(bare.canPublishPosts).toBe(false);
|
|
|
|
await updateUser(user.id, {
|
|
password: null,
|
|
tagIds: [],
|
|
permissions: {
|
|
canCreateTags: true,
|
|
canPublishPosts: false,
|
|
canUnpublishPosts: false,
|
|
canDeletePosts: true,
|
|
canApproveComments: false,
|
|
},
|
|
});
|
|
const reread = await getUserWithTags(user.id);
|
|
expect(reread?.canCreateTags).toBe(true);
|
|
expect(reread?.canPublishPosts).toBe(false);
|
|
expect(reread?.canDeletePosts).toBe(true);
|
|
});
|
|
|
|
it("changes own password only with the correct current password", async () => {
|
|
const user = await createUser({ username: "casey", password: "old-password-1", tagIds: [] });
|
|
const wrong = await changeOwnPassword(user.id, "not-the-password", "new-password-1");
|
|
expect(wrong.ok).toBe(false);
|
|
|
|
const right = await changeOwnPassword(user.id, "old-password-1", "new-password-1");
|
|
expect(right.ok).toBe(true);
|
|
const { users } = await import("@/db/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
const [row] = await db.select().from(users).where(eq(users.id, user.id));
|
|
expect(await verifyPassword(row.passwordHash, "new-password-1")).toBe(true);
|
|
expect(await verifyPassword(row.passwordHash, "old-password-1")).toBe(false);
|
|
});
|
|
|
|
it("grantTags adds without replacing and tolerates duplicates", async () => {
|
|
const [design, writing] = await seedTags("Design", "Writing");
|
|
const user = await createUser({
|
|
username: "casey",
|
|
password: "hunter2hunter2",
|
|
tagIds: [design.id],
|
|
});
|
|
await grantTags(user.id, [writing.id, design.id]);
|
|
expect((await getAllowedTagIds(user.id)).sort()).toEqual(
|
|
[design.id, writing.id].sort(),
|
|
);
|
|
});
|
|
|
|
it("refuses to delete the admin and keeps a deleted author's posts", async () => {
|
|
const admin = await seedAdmin();
|
|
expect((await deleteUser(admin.id)).ok).toBe(false);
|
|
|
|
const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] });
|
|
const post = await createPost(postInput(), author.id);
|
|
expect((await deleteUser(author.id)).ok).toBe(true);
|
|
|
|
// Post survives, now unowned.
|
|
const kept = await getPostById(post.id);
|
|
expect(kept).not.toBeNull();
|
|
expect(kept?.authorId).toBeNull();
|
|
expect(await getUserWithTags(author.id)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("post ownership", () => {
|
|
it("stamps the creating account and filters listAllPosts by author", async () => {
|
|
const admin = await seedAdmin();
|
|
const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] });
|
|
|
|
await createPost(postInput({ title: "Admin post" }), admin.id);
|
|
await createPost(postInput({ title: "Casey post" }), author.id);
|
|
|
|
const all = await listAllPosts();
|
|
expect(all).toHaveLength(2);
|
|
const mine = await listAllPosts({ authorId: author.id });
|
|
expect(mine.map((p) => p.title)).toEqual(["Casey post"]);
|
|
});
|
|
|
|
it("round-trips ownership through export/import by username", async () => {
|
|
await seedAdmin();
|
|
const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] });
|
|
const post = await createPost(postInput({ title: "Owned" }), author.id);
|
|
|
|
const snapshot = await buildSiteExport();
|
|
expect(snapshot.posts.find((p) => p.slug === post.slug)?.authorUsername).toBe("casey");
|
|
|
|
await importSiteExport(snapshot);
|
|
const restored = (await listAllPosts({ authorId: author.id })).find(
|
|
(p) => p.title === "Owned",
|
|
);
|
|
expect(restored).toBeDefined();
|
|
expect(restored?.authorId).toBe(author.id);
|
|
});
|
|
|
|
it("leaves posts unowned when the export references an unknown username", async () => {
|
|
await seedAdmin();
|
|
const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] });
|
|
await createPost(postInput({ title: "Orphan-to-be" }), author.id);
|
|
|
|
const snapshot = await buildSiteExport();
|
|
await deleteUser(author.id);
|
|
await importSiteExport(snapshot);
|
|
|
|
const [post] = await listAllPosts();
|
|
expect(post.authorId).toBeNull();
|
|
});
|
|
});
|