feat(notifications): add account-scoped inbox and persistent preferences
This commit is contained in:
1 parent
8f55ff2d17
commit
1eee6661f9
39 files changed
+1559
-3
No files matched your search
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
auth: vi.fn(),
|
||||
feed: vi.fn(),
|
||||
mark: vi.fn(),
|
||||
preferences: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth", () => ({ auth: mocks.auth }));
|
||||
vi.mock("@/features/notifications/server", () => ({ notifications: mocks }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
|
||||
|
||||
import { GET, POST } from "./route";
|
||||
|
||||
function request(body: unknown, origin = "https://hotel.test") {
|
||||
return new Request("https://hotel.test/api/notifications", {
|
||||
method: "POST",
|
||||
headers: { origin, "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.auth.mockResolvedValue({ user: { id: "7" } });
|
||||
mocks.feed.mockResolvedValue({ items: [] });
|
||||
mocks.mark.mockResolvedValue(true);
|
||||
});
|
||||
describe("notification route authorization", () => {
|
||||
it("rejects anonymous reads and writes without accessing data", async () => {
|
||||
mocks.auth.mockResolvedValue(null);
|
||||
expect(
|
||||
(await GET(new Request("https://hotel.test/api/notifications"))).status,
|
||||
).toBe(401);
|
||||
expect(
|
||||
(await POST(request({ action: "read", key: "support:1" }))).status,
|
||||
).toBe(401);
|
||||
expect(mocks.feed).not.toHaveBeenCalled();
|
||||
expect(mocks.mark).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses the session identity even when a query supplies another account", async () => {
|
||||
await GET(
|
||||
new Request("https://hotel.test/api/notifications?userId=99&page=2"),
|
||||
);
|
||||
expect(mocks.feed).toHaveBeenCalledWith(7, 2);
|
||||
});
|
||||
it("rejects cross-origin mutations", async () => {
|
||||
expect(
|
||||
(
|
||||
await POST(
|
||||
request(
|
||||
{ action: "read", key: "support:1" },
|
||||
"https://attacker.test",
|
||||
),
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(mocks.mark).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects an injected account in writes", async () => {
|
||||
expect(
|
||||
(await POST(request({ action: "read", key: "support:1", userId: 99 })))
|
||||
.status,
|
||||
).toBe(400);
|
||||
expect(mocks.mark).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects unavailable and other account notifications", async () => {
|
||||
mocks.mark.mockResolvedValue(false);
|
||||
expect(
|
||||
(await POST(request({ action: "read", key: "support:1" }))).status,
|
||||
).toBe(404);
|
||||
expect(mocks.mark).toHaveBeenCalledWith(7, "support:1");
|
||||
});
|
||||
it("persists category preferences under the authenticated account", async () => {
|
||||
const preferences = { support: false, friends: true, events: false };
|
||||
expect(
|
||||
(await POST(request({ action: "preferences", preferences }))).status,
|
||||
).toBe(200);
|
||||
expect(mocks.preferences).toHaveBeenCalledWith(7, preferences);
|
||||
});
|
||||
it("does not turn an infrastructure failure into an empty feed", async () => {
|
||||
mocks.feed.mockRejectedValue(new Error("Database unavailable"));
|
||||
expect(
|
||||
(await GET(new Request("https://hotel.test/api/notifications"))).status,
|
||||
).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a failure when saving read state or preferences fails", async () => {
|
||||
mocks.mark.mockRejectedValue(new Error("Database unavailable"));
|
||||
expect(
|
||||
(await POST(request({ action: "read", key: "support:1" }))).status,
|
||||
).toBe(503);
|
||||
mocks.preferences.mockRejectedValue(new Error("Database unavailable"));
|
||||
expect(
|
||||
(
|
||||
await POST(
|
||||
request({
|
||||
action: "preferences",
|
||||
preferences: { support: true, friends: false, events: true },
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(503);
|
||||
});
|
||||
|
||||
it("rejects incomplete and foreign-account preferences", async () => {
|
||||
for (const body of [
|
||||
{ action: "preferences", preferences: { support: false } },
|
||||
{
|
||||
action: "preferences",
|
||||
userId: 99,
|
||||
preferences: { support: false, friends: true, events: true },
|
||||
},
|
||||
])
|
||||
expect((await POST(request(body))).status).toBe(400);
|
||||
expect(mocks.preferences).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
import { notifications } from "@/features/notifications/server";
|
||||
import { apiError, apiJson, apiUnavailable } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const mutation = z.discriminatedUnion("action", [
|
||||
z
|
||||
.object({ action: z.literal("read"), key: z.string().min(1).max(128) })
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
action: z.literal("preferences"),
|
||||
preferences: z
|
||||
.object({
|
||||
support: z.boolean(),
|
||||
friends: z.boolean(),
|
||||
events: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
export async function GET(req: Request) {
|
||||
const uid = sessionUserId((await auth())?.user?.id);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
try {
|
||||
return apiJson(
|
||||
await notifications.feed(
|
||||
uid,
|
||||
Number(new URL(req.url).searchParams.get("page") ?? 1),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return apiUnavailable();
|
||||
}
|
||||
}
|
||||
export async function POST(req: Request) {
|
||||
const uid = sessionUserId((await auth())?.user?.id);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
if (req.headers.get("origin") !== new URL(req.url).origin)
|
||||
return apiError("Invalid origin", 403);
|
||||
if (!req.headers.get("content-type")?.startsWith("application/json"))
|
||||
return apiError("Invalid content type", 415);
|
||||
if (!(await rateLimit(`notifications:${uid}`, 60, 60_000)).ok)
|
||||
return apiError("Too many requests", 429);
|
||||
const body = await req.text();
|
||||
if (body.length > 2048) return apiError("Request too large", 413);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(body);
|
||||
} catch {
|
||||
return apiError("Invalid request");
|
||||
}
|
||||
const parsed = mutation.safeParse(value);
|
||||
if (!parsed.success) return apiError("Invalid request");
|
||||
try {
|
||||
if (parsed.data.action === "read") {
|
||||
if (!(await notifications.mark(uid, parsed.data.key)))
|
||||
return apiError("Notification not found", 404);
|
||||
} else await notifications.preferences(uid, parsed.data.preferences);
|
||||
return apiJson({ ok: true });
|
||||
} catch {
|
||||
return apiUnavailable();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user