-
Notifications
You must be signed in to change notification settings - Fork 126
fix(notifications): honor category prefs before push delivery #1943
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { | ||
| isPushAllowedForCategory, | ||
| notificationTypeToCategory, | ||
| resolvePushCategory, | ||
| } from "../utils/notificationPreferences.js"; | ||
|
|
||
| describe("notificationTypeToCategory", () => { | ||
| it("maps message and session-related types onto settings categories", () => { | ||
| expect(notificationTypeToCategory("message")).toBe("messages"); | ||
| expect(notificationTypeToCategory("session_reminder")).toBe("sessions"); | ||
| expect(notificationTypeToCategory("mentorship_reminder")).toBe("sessions"); | ||
| expect(notificationTypeToCategory("announcement")).toBe("sessions"); | ||
| expect(notificationTypeToCategory("friend_request")).toBe("friends"); | ||
| }); | ||
|
|
||
| it("returns null for unmapped types", () => { | ||
| expect(notificationTypeToCategory("system")).toBeNull(); | ||
| expect(notificationTypeToCategory(undefined)).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("isPushAllowedForCategory", () => { | ||
| it("allows delivery when preferences are missing or the category is unknown", () => { | ||
| expect(isPushAllowedForCategory(null, "messages")).toBe(true); | ||
| expect(isPushAllowedForCategory({}, null)).toBe(true); | ||
| }); | ||
|
|
||
| it("suppresses push when the category inApp channel is disabled", () => { | ||
| const prefs = { | ||
| messages: { inApp: false }, | ||
| sessions: { inApp: true }, | ||
| friends: { inApp: true }, | ||
| }; | ||
|
|
||
| expect(isPushAllowedForCategory(prefs, "messages")).toBe(false); | ||
| expect(isPushAllowedForCategory(prefs, "sessions")).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe("resolvePushCategory", () => { | ||
| it("prefers an explicit category over type mapping", () => { | ||
| expect(resolvePushCategory({ type: "message", category: "friends" })).toBe("friends"); | ||
| expect(resolvePushCategory({ type: "session_reminder" })).toBe("sessions"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -166,8 +166,9 @@ describe("POST /api/users/upload-photo", () => { | |||||||||||||||||||
|
|
||||||||||||||||||||
| expect(res.status).toBe(200); | ||||||||||||||||||||
| expect(res.body.success).toBe(true); | ||||||||||||||||||||
| // Filename must contain the authenticated user's ID | ||||||||||||||||||||
| expect(res.body.fileUrl).toMatch(new RegExp(`profile-${TEST_USER_ID}-`)); | ||||||||||||||||||||
| // Supabase storage path is scoped to the authenticated user's ID | ||||||||||||||||||||
| expect(res.body.fileUrl).toContain(`${TEST_USER_ID}/`); | ||||||||||||||||||||
| expect(storageUploadMock).toHaveBeenCalled(); | ||||||||||||||||||||
|
Comment on lines
+169
to
+171
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win Assert the storage path directly.
Suggested assertion expect(res.body.fileUrl).toContain(`${TEST_USER_ID}/`);
expect(storageUploadMock).toHaveBeenCalled();
+ expect(storageUploadMock.mock.calls[0][0]).toMatch(
+ new RegExp(`^${TEST_USER_ID}/`),
+ );📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it("returns 200 when a valid JWT is supplied via HttpOnly cookie", async () => { | ||||||||||||||||||||
|
|
@@ -221,9 +222,9 @@ describe("POST /api/users/upload-photo", () => { | |||||||||||||||||||
| expect(res.body.error).toMatch(/no file/i); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it("returns 413 when the uploaded file exceeds the 5MB size limit", async () => { | ||||||||||||||||||||
| it("returns 413 when the uploaded file exceeds the 2MB size limit", async () => { | ||||||||||||||||||||
| const token = makeToken(); | ||||||||||||||||||||
| const oversized = Buffer.alloc(6 * 1024 * 1024, 0xff); // 6 MB of 0xFF bytes | ||||||||||||||||||||
| const oversized = Buffer.alloc(3 * 1024 * 1024, 0xff); // 3 MB of 0xFF bytes | ||||||||||||||||||||
| const res = await request(app) | ||||||||||||||||||||
| .post("/api/users/upload-photo") | ||||||||||||||||||||
| .set("Authorization", `Bearer ${token}`) | ||||||||||||||||||||
|
|
@@ -233,7 +234,7 @@ describe("POST /api/users/upload-photo", () => { | |||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| expect(res.status).toBe(413); | ||||||||||||||||||||
| expect(res.body.error).toMatch(/5mb/i); | ||||||||||||||||||||
| expect(res.body.error).toMatch(/2mb/i); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| export const DEFAULT_NOTIFICATION_PREFERENCES = { | ||
| messages: { email: false, inApp: true }, | ||
| sessions: { email: false, inApp: true }, | ||
| friends: { email: false, inApp: true }, | ||
| }; | ||
|
|
||
| /** | ||
| * Map a notifications.type enum value to a Settings preference category. | ||
| * Unmapped types (e.g. system) return null and are treated as allowed. | ||
| */ | ||
| export function notificationTypeToCategory(type) { | ||
| switch (type) { | ||
| case "message": | ||
| return "messages"; | ||
| case "session_reminder": | ||
| case "mentorship_reminder": | ||
| case "mentorship_reminder_overdue": | ||
| case "announcement": | ||
| return "sessions"; | ||
| case "friend_request": | ||
| case "connection_request": | ||
| return "friends"; | ||
| default: | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Push delivery follows the inApp channel for the given category. | ||
| * Missing prefs / unknown categories default to allowing delivery. | ||
| */ | ||
| export function isPushAllowedForCategory(preferences, category) { | ||
| if (!category) return true; | ||
|
|
||
| const prefs = preferences && typeof preferences === "object" | ||
| ? preferences | ||
| : DEFAULT_NOTIFICATION_PREFERENCES; | ||
|
|
||
| const channel = prefs[category]; | ||
| if (!channel || typeof channel !== "object") return true; | ||
|
|
||
| return channel.inApp !== false; | ||
| } | ||
|
|
||
| export function resolvePushCategory({ type, category } = {}) { | ||
| if (typeof category === "string" && category.length > 0) { | ||
| return category; | ||
| } | ||
| if (typeof type === "string" && type.length > 0) { | ||
| return notificationTypeToCategory(type); | ||
| } | ||
| return null; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -130,3 +130,37 @@ Sends a browser push notification to all subscribed devices for a given `user_id | |
| ``` | ||
|
|
||
| **Security**: Standard users may only send push notifications to themselves (IDOR prevention). Webhook callers authenticated via `WEBHOOK_SECRET` may send to any user. | ||
|
|
||
| ## File Upload Routes | ||
|
|
||
| Authenticated multipart uploads are written to Supabase Storage. Storage paths are generated on the server from the caller's user id — clients cannot choose arbitrary object keys. | ||
|
|
||
| ### `POST /api/upload` | ||
|
|
||
| General-purpose upload for `avatars`, `profiles`, and `resources` buckets. | ||
|
|
||
| **Auth**: valid Supabase JWT (`Authorization` header or `access_token` cookie) | ||
|
|
||
| **Form fields**: | ||
| - `folder`: one of `avatars`, `profiles`, `resources` | ||
| - `file`: the file to upload | ||
|
|
||
| **Validation**: | ||
| - MIME type must match the destination folder allow-list | ||
| - Magic byte / content-type verification rejects spoofed uploads | ||
| - Binary content and null bytes are rejected for text resource uploads | ||
|
|
||
| ### `POST /api/users/upload-photo` | ||
|
|
||
| Profile-photo upload into the `profiles` bucket (2MB limit). | ||
|
|
||
| **Auth**: valid Supabase JWT (`Authorization` header or `access_token` cookie) | ||
|
|
||
| **Form fields**: | ||
| - `profilePhoto`: JPEG, PNG, WebP, or GIF image | ||
|
|
||
| **Validation**: | ||
| - 2MB size limit | ||
| - Strict image MIME allow-list | ||
| - Magic byte verification that file content matches the declared image type | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win Correct the profile-photo MIME validation claim.
Update this bullet to describe verification of an allowed image type, or add an explicit comparison in the route. 🤖 Prompt for AI Agents |
||
| - Per-user rate limit (10 uploads per hour) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Handle failures when changing preference-related notification state.
If the profile query fails, check the claim rollback result. A rollback failure leaves claimed notifications unavailable until the claim TTL expires.
If the opt-out
push_sent_atupdate fails, do not report the notification as processed. The row can otherwise be reclaimed and repeatedly evaluated.Proposed fix
Also applies to: 165-168
🤖 Prompt for AI Agents