118 lines
3.7 KiB
TypeScript
118 lines
3.7 KiB
TypeScript
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();
|
|
});
|