yap-blog/tests/integration/users.test.ts
matt 6d84ae1224 Add author accounts with per-tag posting rights
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>
2026-07-04 22:10:02 -04:00

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();
});
});