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>
93 lines
2.9 KiB
TypeScript
93 lines
2.9 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { db } from "@/db";
|
|
import { sessions, users } from "@/db/schema";
|
|
import { hashPassword, verifyPassword } from "@/lib/auth/password";
|
|
import {
|
|
createSession,
|
|
deleteSession,
|
|
validateSessionToken,
|
|
} from "@/lib/auth/session";
|
|
import { resetDb } from "../helpers/db";
|
|
|
|
beforeEach(resetDb);
|
|
|
|
async function insertUser() {
|
|
const [user] = await db
|
|
.insert(users)
|
|
.values({ username: "admin", passwordHash: await hashPassword("correct horse") })
|
|
.returning();
|
|
return user;
|
|
}
|
|
|
|
describe("password hashing", () => {
|
|
it("verifies the original password and rejects others", async () => {
|
|
const hash = await hashPassword("s3cret-passphrase");
|
|
expect(hash.startsWith("scrypt$")).toBe(true);
|
|
expect(hash).not.toContain("s3cret-passphrase");
|
|
expect(await verifyPassword(hash, "s3cret-passphrase")).toBe(true);
|
|
expect(await verifyPassword(hash, "wrong")).toBe(false);
|
|
});
|
|
|
|
it("produces unique salts per hash", async () => {
|
|
expect(await hashPassword("same")).not.toBe(await hashPassword("same"));
|
|
});
|
|
|
|
it("rejects malformed stored hashes without throwing", async () => {
|
|
expect(await verifyPassword("garbage", "x")).toBe(false);
|
|
expect(await verifyPassword("scrypt$bad$data", "x")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("sessions", () => {
|
|
it("round-trips a valid session token", async () => {
|
|
const user = await insertUser();
|
|
const { token, expiresAt } = await createSession(user.id);
|
|
expect(expiresAt.getTime()).toBeGreaterThan(Date.now());
|
|
|
|
const sessionUser = await validateSessionToken(token);
|
|
expect(sessionUser).toEqual({
|
|
id: user.id,
|
|
username: "admin",
|
|
role: "author",
|
|
permissions: {
|
|
createTags: false,
|
|
publishPosts: false,
|
|
unpublishPosts: false,
|
|
deletePosts: false,
|
|
approveComments: false,
|
|
},
|
|
});
|
|
|
|
// Only a hash of the token is stored.
|
|
const rows = await db.select().from(sessions);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].id).not.toBe(token);
|
|
});
|
|
|
|
it("rejects unknown tokens", async () => {
|
|
await insertUser();
|
|
expect(await validateSessionToken("not-a-real-token")).toBeNull();
|
|
expect(await validateSessionToken("")).toBeNull();
|
|
});
|
|
|
|
it("treats expired sessions as absent and deletes them", async () => {
|
|
const user = await insertUser();
|
|
const { token } = await createSession(user.id);
|
|
await db
|
|
.update(sessions)
|
|
.set({ expiresAt: new Date(Date.now() - 1000) })
|
|
.where(eq(sessions.userId, user.id));
|
|
|
|
expect(await validateSessionToken(token)).toBeNull();
|
|
expect(await db.select().from(sessions)).toHaveLength(0);
|
|
});
|
|
|
|
it("invalidates a session on logout", async () => {
|
|
const user = await insertUser();
|
|
const { token } = await createSession(user.id);
|
|
await deleteSession(token);
|
|
expect(await validateSessionToken(token)).toBeNull();
|
|
});
|
|
});
|