feat: let guests publish a CommunityRule and claim it on this browser later
Finalize no longer requires a magic link. Guest rows stay off the catalog until sign-in on the same browser attaches ownership, and the login modal kebab no longer acts as a second close. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const cookieGetMock = vi.fn();
|
||||
const cookieSetMock = vi.fn();
|
||||
const updateManyMock = vi.fn();
|
||||
const getSessionPepperMock = vi.fn();
|
||||
const hashRuleClaimTokenMock = vi.fn();
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: async () => ({
|
||||
get: cookieGetMock,
|
||||
set: cookieSetMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/db", () => ({
|
||||
prisma: {
|
||||
publishedRule: {
|
||||
updateMany: (...args: unknown[]) => updateManyMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
getSessionPepper: () => getSessionPepperMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/hash", () => ({
|
||||
hashRuleClaimToken: (...args: unknown[]) => hashRuleClaimTokenMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/logger", () => ({
|
||||
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import {
|
||||
claimGuestRulesForUser,
|
||||
issueGuestRuleClaimCookie,
|
||||
} from "../../lib/server/guestRuleClaim";
|
||||
|
||||
beforeEach(() => {
|
||||
cookieGetMock.mockReset();
|
||||
cookieSetMock.mockReset();
|
||||
updateManyMock.mockReset();
|
||||
getSessionPepperMock.mockReset();
|
||||
hashRuleClaimTokenMock.mockReset();
|
||||
getSessionPepperMock.mockReturnValue("pepper");
|
||||
hashRuleClaimTokenMock.mockImplementation((token: string) => `h-${token}`);
|
||||
});
|
||||
|
||||
describe("issueGuestRuleClaimCookie", () => {
|
||||
it("stores the token in an httpOnly cookie", async () => {
|
||||
cookieGetMock.mockReturnValue(undefined);
|
||||
await issueGuestRuleClaimCookie("token-aaaaaaaaaaaaaaaa");
|
||||
expect(cookieSetMock).toHaveBeenCalledWith(
|
||||
"cr_rule_claim",
|
||||
JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
|
||||
expect.objectContaining({
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("appends without duplicating", async () => {
|
||||
cookieGetMock.mockReturnValue({
|
||||
value: JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
|
||||
});
|
||||
await issueGuestRuleClaimCookie("token-bbbbbbbbbbbbbbbb");
|
||||
expect(cookieSetMock).toHaveBeenCalledWith(
|
||||
"cr_rule_claim",
|
||||
JSON.stringify(["token-aaaaaaaaaaaaaaaa", "token-bbbbbbbbbbbbbbbb"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claimGuestRulesForUser", () => {
|
||||
it("returns 0 when the cookie is empty", async () => {
|
||||
cookieGetMock.mockReturnValue(undefined);
|
||||
const claimed = await claimGuestRulesForUser("user-1");
|
||||
expect(claimed).toBe(0);
|
||||
expect(updateManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("claims matching ownerless rows and clears the cookie", async () => {
|
||||
cookieGetMock.mockReturnValue({
|
||||
value: JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
|
||||
});
|
||||
updateManyMock.mockResolvedValueOnce({ count: 1 });
|
||||
|
||||
const claimed = await claimGuestRulesForUser("user-1");
|
||||
|
||||
expect(claimed).toBe(1);
|
||||
expect(hashRuleClaimTokenMock).toHaveBeenCalledWith(
|
||||
"token-aaaaaaaaaaaaaaaa",
|
||||
"pepper",
|
||||
);
|
||||
expect(updateManyMock).toHaveBeenCalledWith({
|
||||
where: { claimTokenHash: "h-token-aaaaaaaaaaaaaaaa", userId: null },
|
||||
data: { userId: "user-1", claimTokenHash: null },
|
||||
});
|
||||
expect(cookieSetMock).toHaveBeenCalledWith(
|
||||
"cr_rule_claim",
|
||||
"",
|
||||
expect.objectContaining({ maxAge: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not attach a rule that already has an owner", async () => {
|
||||
cookieGetMock.mockReturnValue({
|
||||
value: JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
|
||||
});
|
||||
updateManyMock.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
const claimed = await claimGuestRulesForUser("user-1");
|
||||
|
||||
expect(claimed).toBe(0);
|
||||
expect(cookieSetMock).toHaveBeenCalledWith(
|
||||
"cr_rule_claim",
|
||||
"",
|
||||
expect.objectContaining({ maxAge: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hashRuleClaimToken, hashSessionToken } from "../../lib/server/hash";
|
||||
|
||||
describe("hashRuleClaimToken", () => {
|
||||
it("is stable for the same token and pepper", () => {
|
||||
const a = hashRuleClaimToken("secret-token", "pepper");
|
||||
const b = hashRuleClaimToken("secret-token", "pepper");
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("differs from the session hash of the same token", () => {
|
||||
const claim = hashRuleClaimToken("secret-token", "pepper");
|
||||
const session = hashSessionToken("secret-token", "pepper");
|
||||
expect(claim).not.toBe(session);
|
||||
});
|
||||
});
|
||||
@@ -28,11 +28,13 @@ vi.mock("../../../lib/create/lastPublishedRule", () => ({
|
||||
}));
|
||||
|
||||
const emptyState = {} as CreateFlowState;
|
||||
const signedIn = { id: "user-1", email: "owner@example.com" };
|
||||
|
||||
describe("useCreateFlowFinalize", () => {
|
||||
const router = { push: vi.fn() };
|
||||
const updateState = vi.fn();
|
||||
const openLogin = vi.fn();
|
||||
const onGuestPublished = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(publishRule).mockReset();
|
||||
@@ -41,6 +43,7 @@ describe("useCreateFlowFinalize", () => {
|
||||
router.push.mockReset();
|
||||
updateState.mockReset();
|
||||
openLogin.mockReset();
|
||||
onGuestPublished.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -61,6 +64,7 @@ describe("useCreateFlowFinalize", () => {
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
sessionUser: signedIn,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -80,6 +84,99 @@ describe("useCreateFlowFinalize", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not publish while the session is unresolved", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: emptyState,
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
sessionUser: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize();
|
||||
});
|
||||
|
||||
expect(publishRule).not.toHaveBeenCalled();
|
||||
expect(router.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("omits stakeholder emails when a guest publishes with invites in state", async () => {
|
||||
vi.mocked(publishRule).mockResolvedValue({
|
||||
ok: true,
|
||||
id: "guest-rule-id",
|
||||
title: "Published title",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: {
|
||||
...emptyState,
|
||||
stakeholderEmails: ["invitee@example.com"],
|
||||
},
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
sessionUser: null,
|
||||
onGuestPublished,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize();
|
||||
});
|
||||
|
||||
expect(publishRule).toHaveBeenCalledWith({
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
});
|
||||
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: true });
|
||||
expect(openLogin).not.toHaveBeenCalled();
|
||||
expect(router.push).toHaveBeenCalledWith(
|
||||
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("hints to claim later when a guest publishes without stakeholder emails", async () => {
|
||||
vi.mocked(publishRule).mockResolvedValue({
|
||||
ok: true,
|
||||
id: "guest-rule-id",
|
||||
title: "Published title",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: emptyState,
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
sessionUser: null,
|
||||
onGuestPublished,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize();
|
||||
});
|
||||
|
||||
expect(publishRule).toHaveBeenCalledWith({
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
});
|
||||
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: false });
|
||||
expect(openLogin).not.toHaveBeenCalled();
|
||||
expect(router.push).toHaveBeenCalledWith(
|
||||
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes stakeholderEmails to publishRule on initial publish", async () => {
|
||||
vi.mocked(publishRule).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -97,6 +194,7 @@ describe("useCreateFlowFinalize", () => {
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
sessionUser: signedIn,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -124,6 +222,7 @@ describe("useCreateFlowFinalize", () => {
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/edit-rule",
|
||||
sessionUser: signedIn,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
const findManyMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/db", () => ({
|
||||
prisma: {
|
||||
publishedRule: {
|
||||
findMany: (...args: unknown[]) => findManyMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from "../../app/api/rules/route";
|
||||
|
||||
beforeEach(() => {
|
||||
isDatabaseConfiguredMock.mockReset();
|
||||
findManyMock.mockReset();
|
||||
});
|
||||
|
||||
describe("GET /api/rules", () => {
|
||||
it("returns 503 when the database is not configured", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/rules"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
expect(findManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists owned rules only", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
findManyMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: "r1",
|
||||
title: "Owned",
|
||||
summary: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/rules?limit=10"),
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(findManyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: { not: null } },
|
||||
take: 10,
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as { rules: Array<{ id: string }> };
|
||||
expect(body.rules).toEqual([{ id: "r1", title: "Owned", summary: null, createdAt: expect.any(String), updatedAt: expect.any(String) }]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ const transactionMock = vi.fn();
|
||||
const publishedRuleCreateMock = vi.fn();
|
||||
const publishedRuleDeleteMock = vi.fn();
|
||||
const sendInviteMock = vi.fn();
|
||||
const rateLimitKeyMock = vi.fn(() => ({ ok: true as const }));
|
||||
const issueGuestRuleClaimCookieMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => true,
|
||||
@@ -17,7 +19,7 @@ vi.mock("../../lib/server/session", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/rateLimit", () => ({
|
||||
rateLimitKey: () => ({ ok: true as const }),
|
||||
rateLimitKey: (...args: unknown[]) => rateLimitKeyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/mail", () => ({
|
||||
@@ -28,6 +30,12 @@ vi.mock("../../lib/server/mail", () => ({
|
||||
vi.mock("../../lib/server/hash", () => ({
|
||||
newSessionToken: () => "x".repeat(32),
|
||||
hashSessionToken: (t: string) => `hashed-${t}`,
|
||||
hashRuleClaimToken: (t: string) => `claim-${t}`,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/guestRuleClaim", () => ({
|
||||
issueGuestRuleClaimCookie: (...args: unknown[]) =>
|
||||
issueGuestRuleClaimCookieMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/db", () => ({
|
||||
@@ -52,6 +60,9 @@ beforeEach(() => {
|
||||
publishedRuleCreateMock.mockReset();
|
||||
publishedRuleDeleteMock.mockReset();
|
||||
sendInviteMock.mockReset();
|
||||
rateLimitKeyMock.mockReset();
|
||||
issueGuestRuleClaimCookieMock.mockReset();
|
||||
rateLimitKeyMock.mockReturnValue({ ok: true as const });
|
||||
getSessionUserMock.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "owner@example.com",
|
||||
@@ -82,6 +93,14 @@ describe("POST /api/rules", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(transactionMock).not.toHaveBeenCalled();
|
||||
expect(publishedRuleCreateMock).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(
|
||||
publishedRuleCreateMock.mock.calls[0]?.[0] as {
|
||||
data: { claimTokenHash?: unknown };
|
||||
}
|
||||
).data.claimTokenHash,
|
||||
).toBeUndefined();
|
||||
expect(issueGuestRuleClaimCookieMock).not.toHaveBeenCalled();
|
||||
expect(sendInviteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -171,4 +190,70 @@ describe("POST /api/rules", () => {
|
||||
where: { id: "rule-new" },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an unlisted rule when there is no session", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
publishedRuleCreateMock.mockResolvedValueOnce({
|
||||
id: "rule-guest",
|
||||
title: "Guest rule",
|
||||
summary: null,
|
||||
createdAt: new Date("2026-01-03T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/rules", {
|
||||
method: "POST",
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
body: JSON.stringify({
|
||||
title: "Guest rule",
|
||||
summary: null,
|
||||
document: {},
|
||||
stakeholderEmails: ["invitee@example.com"],
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(rateLimitKeyMock).toHaveBeenCalledWith(
|
||||
"publish-anonymous-ip:203.0.113.10",
|
||||
60_000,
|
||||
);
|
||||
expect(publishedRuleCreateMock).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: null,
|
||||
title: "Guest rule",
|
||||
claimTokenHash: `claim-${"x".repeat(32)}`,
|
||||
}),
|
||||
});
|
||||
expect(issueGuestRuleClaimCookieMock).toHaveBeenCalledWith("x".repeat(32));
|
||||
expect(transactionMock).not.toHaveBeenCalled();
|
||||
expect(sendInviteMock).not.toHaveBeenCalled();
|
||||
const body = (await res.json()) as { rule: { id: string } };
|
||||
expect(body.rule.id).toBe("rule-guest");
|
||||
});
|
||||
|
||||
it("rate-limits anonymous publish by IP", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
rateLimitKeyMock.mockReturnValueOnce({
|
||||
ok: false as const,
|
||||
retryAfterMs: 12_000,
|
||||
});
|
||||
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/rules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: "Guest rule",
|
||||
summary: null,
|
||||
document: {},
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(publishedRuleCreateMock).not.toHaveBeenCalled();
|
||||
expect(issueGuestRuleClaimCookieMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,10 +38,15 @@ vi.mock("next/headers", () => ({
|
||||
cookies: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/guestRuleClaim", () => ({
|
||||
claimGuestRulesForUser: vi.fn().mockResolvedValue(0),
|
||||
}));
|
||||
|
||||
import {
|
||||
createSessionForUser,
|
||||
pruneExpiredSessions,
|
||||
} from "../../lib/server/session";
|
||||
import { claimGuestRulesForUser } from "../../lib/server/guestRuleClaim";
|
||||
|
||||
beforeEach(() => {
|
||||
sessionCreateMock.mockReset();
|
||||
@@ -50,6 +55,8 @@ beforeEach(() => {
|
||||
newSessionTokenMock.mockReset();
|
||||
hashSessionTokenMock.mockReset();
|
||||
loggerWarnMock.mockReset();
|
||||
vi.mocked(claimGuestRulesForUser).mockReset();
|
||||
vi.mocked(claimGuestRulesForUser).mockResolvedValue(0);
|
||||
|
||||
getSessionPepperMock.mockReturnValue("test-pepper");
|
||||
newSessionTokenMock.mockReturnValue("token-raw");
|
||||
@@ -109,6 +116,7 @@ describe("createSessionForUser cleanup behaviour", () => {
|
||||
expect(result.token).toBe("token-raw");
|
||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||
expect(sessionCreateMock).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(claimGuestRulesForUser)).toHaveBeenCalledWith("user-1");
|
||||
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
|
||||
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
|
||||
where: { userId?: string; expiresAt: { lt: Date } };
|
||||
|
||||
Reference in New Issue
Block a user