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>
82 lines
2.7 KiB
TypeScript
82 lines
2.7 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" });
|
|
|
|
// 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();
|
|
});
|
|
});
|