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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user