The seeded account is the single admin; it can create author accounts on the new /admin/users page (username + password) and grant each one access to specific tags. Authors sign in to a Posts-only panel where they can write, edit, publish, and unpublish their own posts — every post must carry at least one granted tag, tags outside the grants are rejected server-side, and only the admin can create tags or delete posts (or anything else: pages, comments, settings, and backups stay admin-only). Admin-only URLs bounce authors to their post list, and foreign post editors 404. posts.author_id records ownership; deleting an account keeps its posts as unowned, admin-managed rows and signs the account out everywhere. Backups (export v3) store the owner's username per post and re-attach ownership on import when the account still exists. Also fixes a latent form bug: a missing newTags field (author forms don't render it) failed zod validation with an invisible error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
153 lines
5.5 KiB
TypeScript
153 lines
5.5 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 {
|
|
createUser,
|
|
deleteUser,
|
|
getAllowedTagIds,
|
|
getUserWithTags,
|
|
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("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();
|
|
});
|
|
});
|