diff --git a/docs/api.md b/docs/api.md index 292d8e6b0..2d4040b93 100644 --- a/docs/api.md +++ b/docs/api.md @@ -122,6 +122,7 @@ One browser tab's content writes to an item are ordered by the server, also **pe - `GET /api/v1/me/ui-dismissals`, `PUT /api/v1/me/ui-dismissals/{key}` — one-time UI suggestions the caller dismissed (TASK-3452: the first-time tutorial cards on /console and the workspace launchpad), stored on the user (`users.ui_dismissals`, a JSON set) so a dismissal holds on every device. Keys are a closed set (`models.UIDismissalKeys`); any other answers 400. A column rather than a table by ruling, until a third kind of dismissal appears. Web client only - `PUT|DELETE /api/v1/me/push-devices/{connection_id}` — register or remove the caller's push device (TASK-3938, PLAN-3908 phase 2). **Session-only**: a cookie or a `padsess_` bearer. An API token, an OAuth bearer (also with a session cookie riding along), a token with a cookie, and an app principal all get `403 session_required`; a workspace-pinned API token is refused before that by the BUG-3982 gate (`403 permission_denied`, `details.reason: "token_workspace_pinned"`); no credential is `401`. After that gate, `404 push_unavailable` when the server has no gateway (the app stops trying that server until its next start, and treats any other 404 as a retry, so the error code is a contract), then `400 validation_error` unless `connection_id` matches `^[A-Za-z0-9-]{1,64}$`. PUT body is strict: `{platform: "ios"|"android", token, app_version?, environment?}`; `token` is non-empty, at most 4096 bytes and NUL-free, `app_version` at most 64 characters, an unknown or repeated member is `400`. `environment` is accepted for client compatibility and IGNORED: every token is an FCM token and FCM routes APNs itself. Success is `200 {id, connection_id, platform, app_version, updated_at}`; DELETE is `204`, also when nothing was registered, and removes only the device bound to the caller's own session under that connection id. **Register is one transaction**: when this user already holds the token the row is UPDATED in place (TASK-4027): it keeps its `id` and `created_at` (the device's first registration, which the dispatcher's predates-device gate reads), moves to the caller's session, refreshes `updated_at`, evicts nothing, and the caller's session's row for any OTHER token is deleted first. Otherwise it deletes every row on EITHER unique key (the caller's session's own row, whatever its token, and this user's row for this token, whatever its session), evicts, then inserts. Either way, re-registering after a re-sign-in or an FCM token rotation leaves exactly one row, on the caller's session. When two users hold one token, the latest registration (the registration sequence `reg_seq`, advanced by every register, TASK-4031) owns it for dispatch. **Cap: 20 devices per user.** At the cap the device with the oldest `updated_at` (ties: `created_at`, then id) is evicted SILENTLY (a debug log of the device id, never the token); replacing the caller's own row evicts nothing. A session that is gone or expired between admission and the write is `401`. **A device lives and dies with its session**: logout, session expiry and the expired-session sweep delete the row (`push_devices.session_id` cascades), and so does deleting the account. **A credential change** (password change, 2FA disable, provider unlink) rotates sessions in one transaction (`Store.RotateUserSessions`): the CALLER's devices are re-bound to the replacement session and survive, while devices on the user's OTHER sessions die with those sessions, since the change exists to cut off stolen sessions and their push tokens go with them. If that transaction fails every session is revoked and the call answers 500 (fail closed). Self-hosted servers send nothing unless they configure a gateway. - `GET|PUT /api/v1/me/notification-settings` — the caller's recipient-side push filters (TASK-3938): `{push_enabled, notify_assigned, notify_comments, notify_replies, notify_reminders, notify_mentions, include_agent_activity}`, seven booleans. GET returns the defaults (everything on except `include_agent_activity`) when the user never saved any. PUT is a full replace: all seven keys are required, and a missing, unknown, null or non-boolean one is `400`, so nothing is silently reset. Available to any authenticated person, an API token included; an app principal is `403`, and so is a token pinned to one workspace, refused by the BUG-3982 gate (`403 permission_denied`, `details.reason: "token_workspace_pinned"`) because these are account-level settings. Works without a push gateway. +- `GET /api/v1/me/notifications/{id}` — what a push's `nid` resolves to (TASK-3939 B2b): `{id, kind, workspace: {slug, name}, item: {ref, title}, actor_name, excerpt, path}`. `excerpt` is the comment's text with whitespace collapsed, cut to 140 runes, and empty for a kind with no comment; `path` is the web route of the item, `/{owner}/{workspace}/{collection}/{item}`. Every refusal the handler makes is one `404 not_found` with identical status, headers and body bytes: no such id, another user's row, and each denial of the dispatcher's own gate (no longer a member, workspace or item gone, item not visible to the caller as a bearer, comment tombstoned or deleted, no longer assigned), so the endpoint cannot be used to tell them apart. Two principals are refused BEFORE the handler, with one answer for every id, real or unknown: a disabled account is `403 account_disabled` (from `RequireAuth`), and an installed app's `padapp_` token, which opens only `/api/app/v1`, is `401` (a user of kind app has no session door either). A `500` is an operational error (a store lookup failed), not a denial: it is not the 404 and says nothing about the id. The caller's notification SETTINGS are never consulted: they decide whether a push is sent, not whether one already delivered may be read. Any authenticated person, an API token included; `401` with no credential; a token pinned to one workspace is `403 token_workspace_pinned` (account-level route, not in the pin allow-list). Not on the MCP surface: it exists for the phone app resolving a push, and no agent workflow reads it. Works without a push gateway. - `GET /api/v1/auth/session` — auth status (`setup_required`, `setup_method`, `auth_method`, `authenticated`, `email_configured`, `push_available`, `user`). `push_available` is a JSON bool (TASK-3938): true only when this server has a push gateway (`PAD_PUSH_GATEWAY_URL` non-empty), and the app enables push only on JSON `true`. It is present only for a SIGNED-IN caller: absent for an anonymous caller and on the pre-auth setup state, since whether a gateway exists is server configuration an anonymous caller does not need (an absent value reads as off) - `POST /api/v1/auth/bootstrap` — create the first admin account from localhost on a fresh instance - `POST /api/v1/auth/register` — create account (admin-created or invitation-based after setup). An `invitation_code` signup claims the invitation in the same transaction as the membership (BUG-3438). If the invitation was declined, cancelled or replaced since the code was read: when the code was the signup's ADMISSION (registration is otherwise closed) the account is rolled back and the answer is `404 not_found` (`500` if the rollback itself fails, since the account then still holds the email); where the signup would have been admitted anyway (Pad Cloud self-serve, or an admin creating the account) the account is KEPT, joins nothing, and the 201 carries `invitation_not_joined: {code, message}` (`invitation_gone`, `inviter_scope_changed` when the inviter is now collection-restricted (BUG-3634) or no longer an owner (BUG-3770); `403` with that code in the admission case, or `invitation_not_applied` for another refused claim) instead of `accepted_invitation`. Additive, omitempty diff --git a/internal/server/handlers_notifications.go b/internal/server/handlers_notifications.go new file mode 100644 index 000000000..1c00362b8 --- /dev/null +++ b/internal/server/handlers_notifications.go @@ -0,0 +1,127 @@ +package server + +import ( + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/store" +) + +// notificationExcerptRunes is the longest excerpt GET /me/notifications/{id} +// returns. +const notificationExcerptRunes = 140 + +// writeNotificationNotFound is the ONLY way the HANDLER of GET +// /me/notifications/{id} says no. A missing id, another user's row and every +// denial of the shared notificationAccess gate (not a member, workspace or item +// gone, not visible, comment tombstoned, no longer assigned) answer these exact +// bytes, so the endpoint cannot be used to learn which of them it was. A +// disabled account (403) and an app token (401) are refused earlier, by the +// middleware, with one answer for every id; a store failure is a 500, an +// operational error and not a denial. +func writeNotificationNotFound(w http.ResponseWriter) { + writeError(w, http.StatusNotFound, "not_found", "Notification not found") +} + +// handleGetMyNotification answers GET /api/v1/me/notifications/{id} +// (TASK-3939 B2b): what the app shows after a content-free push, which carries +// only an id. It runs the dispatcher's own gate, so the push and its +// enrichment can never disagree about what the recipient may see. The +// recipient's notification SETTINGS are deliberately not consulted: they +// govern whether a push is SENT, never whether an already delivered one may be +// read. +func (s *Server) handleGetMyNotification(w http.ResponseWriter, r *http.Request) { + if appContextFrom(r) != nil { + if currentUser(r) == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + writeNotificationNotFound(w) + return + } + user := currentUser(r) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + n, err := s.store.GetNotificationForUser(user.ID, chi.URLParam(r, "id")) + if errors.Is(err, store.ErrNotificationNotFound) { + writeNotificationNotFound(w) + return + } + if err != nil { + writeInternalError(w, err) + return + } + cache := newPushGateCache() + item, reason, err := s.notificationAccess(cache, *n) + if err != nil { + writeInternalError(w, err) + return + } + if reason != "" { + writeNotificationNotFound(w) + return + } + ws, err := s.store.GetWorkspaceByID(n.WorkspaceID) + if err != nil { + writeInternalError(w, err) + return + } + if ws == nil { + writeNotificationNotFound(w) + return + } + owner := ws.OwnerUsername + if owner == "" && ws.OwnerID != "" { + u, err := s.store.GetUser(ws.OwnerID) + if err != nil { + writeInternalError(w, err) + return + } + if u != nil { + owner = u.Username + } + } + // A deleted actor is a nil user and an empty name; a store error is a 500 + // (TASK-3939 B2b codex r3). + actor := "" + if n.ActorUserID != "" { + u, err := s.store.GetUser(n.ActorUserID) + if err != nil { + writeInternalError(w, err) + return + } + if u != nil { + actor = u.Name + } + } + // The gate loaded the live comment for the comment kinds; nothing is read + // or shown from it before the gate has passed. + excerpt := "" + if cm := cache.comments[n.CommentID]; cm != nil && n.CommentID != "" { + excerpt = notificationExcerpt(cm.Body) + } + writeJSON(w, http.StatusOK, map[string]any{ + "id": n.ID, + "kind": n.Kind, + "workspace": map[string]string{"slug": ws.Slug, "name": ws.Name}, + "item": map[string]string{"ref": item.Ref, "title": item.Title}, + "actor_name": actor, + "excerpt": excerpt, + "path": "/" + owner + "/" + ws.Slug + "/" + item.CollectionSlug + "/" + item.Slug, + }) +} + +// notificationExcerpt collapses runs of whitespace and keeps the first +// notificationExcerptRunes runes. +func notificationExcerpt(body string) string { + s := strings.Join(strings.Fields(body), " ") + if r := []rune(s); len(r) > notificationExcerptRunes { + return string(r[:notificationExcerptRunes]) + } + return s +} diff --git a/internal/server/server.go b/internal/server/server.go index 0aad4103b..9bc32260b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2122,6 +2122,8 @@ func (s *Server) setupRouter() { r.Delete("/me/push-devices/{connection_id}", s.handleDeletePushDevice) r.Get("/me/notification-settings", s.handleGetNotificationSettings) r.Put("/me/notification-settings", s.handlePutNotificationSettings) + // What a push's id resolves to (TASK-3939 B2b). + r.Get("/me/notifications/{id}", s.handleGetMyNotification) r.Get("/me/ui-dismissals", s.handleListUIDismissals) r.Put("/me/ui-dismissals/{key}", s.handleDismissUI) r.Get("/tutorials", s.handleListTutorials) diff --git a/internal/server/task3939b2b_notification_test.go b/internal/server/task3939b2b_notification_test.go new file mode 100644 index 000000000..9030f47b6 --- /dev/null +++ b/internal/server/task3939b2b_notification_test.go @@ -0,0 +1,416 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/PerpetualSoftware/pad/internal/models" + "github.com/PerpetualSoftware/pad/internal/notify" + "github.com/PerpetualSoftware/pad/internal/store" +) + +// TASK-3939 part B2b: the real-enqueue mention leg and the enrichment endpoint +// GET /me/notifications/{id}. + +// getNotif asks for notification id as the holder of session token tok. +func getNotif(srv *Server, tok, id string) *httptest.ResponseRecorder { + return doRequestWithCookie(srv, "GET", "/api/v1/me/notifications/"+id, nil, tok) +} + +// bobSession is a live session for bob; the plaintext is the cookie value. +func (f *pdx) bobSession() string { + f.t.Helper() + plain, err := f.srv.store.CreateSession(f.bob.ID, "web", "", "", time.Hour) + must(f.t, err) + return plain +} + +// sameBytes fails unless a and b are the same response: status, every header, +// and the body byte for byte. +func sameBytes(t *testing.T, what string, a, b *httptest.ResponseRecorder) { + t.Helper() + if a.Code != b.Code || !reflect.DeepEqual(a.Header(), b.Header()) || !bytes.Equal(a.Body.Bytes(), b.Body.Bytes()) { + t.Fatalf("%s differs from a missing id:\n got %d %v %q\n want %d %v %q", + what, a.Code, a.Header(), a.Body.String(), b.Code, b.Header(), b.Body.String()) + } +} + +// Every denial of the shared gate answers the bytes a missing id answers, and +// every other row (settings off, no device, the controls) resolves: a setting +// governs the push, never read access. +func TestNotificationEnrichment_GateDenialsAreOneIndistinguishable404(t *testing.T) { + denied := map[string]bool{ + skipNotMember: true, skipItemGone: true, skipWorkspaceGone: true, skipNotVisible: true, + skipNoLongerAssign: true, skipCommentGone: true, skipUserDisabled: true, + } + needsBackends(t, func(t *testing.T, srv *Server) { + for _, c := range gateCases() { + t.Run(c.name, func(t *testing.T) { + f := newPdx(t, srv) + tok := f.bobSession() + id := f.row(c.kind, c.row...) + if c.setup != nil { + c.setup(f, id) + } + // Every case goes through the real router with bob's real + // session, so a middleware refusal (a disabled account is + // refused 403 before the handler) is what the comparison sees. + missing := getNotif(srv, tok, "no-such-notification") + wantMissing := http.StatusNotFound + if c.name == "user_disabled" || c.name == "user_reason_wins_over_membership" { + wantMissing = http.StatusForbidden + } + if c.name == "app_principal" { + // A user of kind app has no session door: 401 for every id. + wantMissing = http.StatusUnauthorized + } + if missing.Code != wantMissing { + t.Fatalf("missing id: %d %s, want %d", missing.Code, missing.Body.String(), wantMissing) + } + rr := getNotif(srv, tok, id) + if denied[c.reason] { + sameBytes(t, c.reason, rr, missing) + return + } + if rr.Code != http.StatusOK { + t.Fatalf("reason %q must still resolve: %d %s", c.reason, rr.Code, rr.Body.String()) + } + }) + } + }) +} + +// A notification setting that is off governs the push only. The endpoint must +// not consult it: an already delivered push still resolves. +func TestNotificationEnrichment_SettingsNeverGovernRead(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + f := newPdx(t, srv) + tok := f.bobSession() + id := f.row("comment") + f.settings(func(s *models.NotificationSettings) { + s.PushEnabled, s.NotifyComments, s.NotifyAssigned, s.NotifyMentions = false, false, false, false + }) + if rr := getNotif(srv, tok, id); rr.Code != http.StatusOK { + t.Fatalf("settings off: %d %s, want 200", rr.Code, rr.Body.String()) + } + agent := f.row("comment", func(o *rowOpt) { o.agent = true }) + if rr := getNotif(srv, tok, agent); rr.Code != http.StatusOK { + t.Fatalf("agent activity off: %d %s, want 200", rr.Code, rr.Body.String()) + } + }) +} + +// Another user's row and a missing id are the same bytes. +func TestNotificationEnrichment_OtherUsersGetTheMissingIdBytes(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + f := newPdx(t, srv) + id := f.row("comment") + ownerTok, err := srv.store.CreateSession(f.owner.ID, "web", "", "", time.Hour) + must(t, err) + missing := getNotif(srv, ownerTok, "no-such-notification") + if missing.Code != http.StatusNotFound { + t.Fatalf("missing: %d", missing.Code) + } + sameBytes(t, "another user's row", getNotif(srv, ownerTok, id), missing) + // Control: the recipient reads it. + if rr := getNotif(srv, f.bobSession(), id); rr.Code != http.StatusOK { + t.Fatalf("control: %d %s", rr.Code, rr.Body.String()) + } + }) +} + +// A tombstoned comment's text never reaches the response, in any form. +func TestNotificationEnrichment_TombstonedCommentTextNeverLeaks(t *testing.T) { + f := newPdx(t, testServer(t)) + tok := f.bobSession() + id := f.row("comment") + if rr := getNotif(f.srv, tok, id); !strings.Contains(rr.Body.String(), "SECRET-COMMENT") { + t.Fatalf("control: the live comment's excerpt is missing: %d %s", rr.Code, rr.Body.String()) + } + f.exec(`UPDATE comments SET deleted_at = ? WHERE id = ?`, f.ts(time.Minute), f.comment.ID) + rr := getNotif(f.srv, tok, id) + if rr.Code != http.StatusNotFound || strings.Contains(rr.Body.String(), "SECRET") { + t.Fatalf("tombstoned: %d %s", rr.Code, rr.Body.String()) + } +} + +type notifBody struct { + ID string `json:"id"` + Kind string `json:"kind"` + Workspace map[string]string `json:"workspace"` + Item map[string]string `json:"item"` + ActorName string `json:"actor_name"` + Excerpt string `json:"excerpt"` + Path string `json:"path"` +} + +func TestNotificationEnrichment_ShapeForEachKind(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + for _, kind := range []string{"comment", "reply", "mention", "assigned", "reminder"} { + t.Run(kind, func(t *testing.T) { + f := newPdx(t, srv) + id := f.row(kind) + rr := getNotif(srv, f.bobSession(), id) + if rr.Code != http.StatusOK { + t.Fatalf("%d %s", rr.Code, rr.Body.String()) + } + var b notifBody + if err := json.Unmarshal(rr.Body.Bytes(), &b); err != nil { + t.Fatal(err) + } + wantExcerpt := "" + if kind == "comment" || kind == "reply" || kind == "mention" { + wantExcerpt = "SECRET-COMMENT" + } + if b.ID != id || b.Kind != kind || b.Workspace["slug"] != f.ws.Slug || b.Workspace["name"] != f.ws.Name || + b.Item["title"] != "SECRET-ITEM-TITLE" || b.Item["ref"] == "" || + b.ActorName != "owner" || b.Excerpt != wantExcerpt { + t.Fatalf("body = %+v", b) + } + want := "/" + f.owner.Username + "/" + f.ws.Slug + "/tasks/" + f.item.Slug + if b.Path != want { + t.Fatalf("path = %q, want %q", b.Path, want) + } + }) + } + }) +} + +// The path is a route of the web app: one leading slash and the four segments +// of web/src/routes/[username]/[workspace]/[collection]/[slug]. +func TestNotificationEnrichment_PathMatchesTheWebRouteTree(t *testing.T) { + if _, err := os.Stat("../../web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte"); err != nil { + t.Fatalf("the web route the path targets is gone: %v", err) + } + f := newPdx(t, testServer(t)) + var b notifBody + rr := getNotif(f.srv, f.bobSession(), f.row("assigned")) + if err := json.Unmarshal(rr.Body.Bytes(), &b); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(b.Path, "/") || strings.HasPrefix(b.Path, "//") { + t.Fatalf("path %q must start with a single slash", b.Path) + } + segs := strings.Split(strings.TrimPrefix(b.Path, "/"), "/") + if len(segs) != 4 { + t.Fatalf("path %q has %d segments, want [username]/[workspace]/[collection]/[slug]", b.Path, len(segs)) + } + for _, s := range segs { + if s == "" { + t.Fatalf("path %q has an empty segment", b.Path) + } + } +} + +func TestNotificationEnrichment_ExcerptCollapsesWhitespaceAndKeeps140Runes(t *testing.T) { + f := newPdx(t, testServer(t)) + body := " first\n\n\tsecond third " + strings.Repeat("é", 300) + cm, err := f.srv.store.CreateComment(f.ws.ID, f.item.ID, f.owner.ID, models.CommentCreate{Body: body, Author: "Owner", CreatedBy: "user", Source: "web"}) + must(t, err) + id := f.row("comment", func(o *rowOpt) { o.comment = cm.ID }) + var b notifBody + rr := getNotif(f.srv, f.bobSession(), id) + if err := json.Unmarshal(rr.Body.Bytes(), &b); err != nil { + t.Fatalf("%d %s", rr.Code, rr.Body.String()) + } + want := "first second third " + strings.Repeat("é", 140-len("first second third ")) + if b.Excerpt != want || utf8.RuneCountInString(b.Excerpt) != 140 { + t.Fatalf("excerpt = %q (%d runes), want %q", b.Excerpt, utf8.RuneCountInString(b.Excerpt), want) + } + // A short body is whole, and exactly 140 runes is not cut. + if got := notificationExcerpt(strings.Repeat("x", 140)); len(got) != 140 { + t.Fatalf("140 runes cut to %d", len(got)) + } +} + +func TestNotificationEnrichment_Unauthenticated401(t *testing.T) { + f := newPdx(t, testServer(t)) + rr := doRequest(f.srv, "GET", "/api/v1/me/notifications/"+f.row("comment"), nil) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("%d %s, want 401", rr.Code, rr.Body.String()) + } +} + +// A token pinned to one workspace is refused: this is an account-level route. +func TestNotificationEnrichment_PinnedTokenRefused(t *testing.T) { + f := newPinFixture(t, store.DriverSQLite) + f.mustBePinDenial(f.do("GET", "/api/v1/me/notifications/anything", f.pinned, nil), "pinned GET /me/notifications/{id}") + // Control: the unpinned token reaches the handler, which has no such row. + f.must(f.do("GET", "/api/v1/me/notifications/anything", f.ownerTok, nil), http.StatusNotFound, "unpinned GET /me/notifications/{id}") +} + +// An unpinned personal API token resolves its own user's notification through +// the router, as docs/api.md promises (TASK-3939 B2b codex r2). +func TestNotificationEnrichment_UnpinnedBearerTokenReadsItsOwnRow(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + f := newPdx(t, srv) + id := f.row("comment") + tok, err := srv.store.CreateAPIToken(f.bob.ID, models.APITokenCreate{Name: "phone"}, 0, 0) + must(t, err) + req := httptest.NewRequest("GET", "/api/v1/me/notifications/"+id, nil) + req.Header.Set("Authorization", "Bearer "+tok.Token) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"id":"`+id+`"`) { + t.Fatalf("bearer GET own row: %d %s, want 200 carrying id %s", rr.Code, rr.Body.String(), id) + } + }) +} + +// The mention leg runs the REAL enqueue: a comment @-mentioning bob is written +// through the store, and nothing seeds a row by hand. The dispatcher then +// pushes it as a mention. +func TestPushDispatch_RealMentionEnqueueThroughTheDispatcher(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + mention := func(f *pdx, who ...*models.User) *models.Comment { + t.Helper() + var tags []string + for _, u := range who { + tags = append(tags, "@"+u.Username) + } + cm, err := srv.store.CreateComment(f.ws.ID, f.item.ID, f.owner.ID, + models.CommentCreate{Body: "ping " + strings.Join(tags, " "), Author: "Owner", CreatedBy: "user", Source: "web"}) + must(t, err) + return cm + } + rowsFor := func(f *pdx, u *models.User, kind string) []string { + t.Helper() + rows, err := srv.store.DB().Query(srv.store.D().Rebind( + `SELECT id FROM user_notifications WHERE user_id = ? AND kind = ?`), u.ID, kind) + must(t, err) + defer rows.Close() + var ids []string + for rows.Next() { + var id string + must(t, rows.Scan(&id)) + ids = append(ids, id) + } + return ids + } + age := func(f *pdx) { f.exec(`UPDATE user_notifications SET created_at = ?`, f.ts(2*time.Minute)) } + + t.Run("pushed_as_a_mention", func(t *testing.T) { + f := newPdx(t, srv) + f.device(f.bob, "tok-bob-second-"+fmt.Sprint(f.n), 4*time.Hour) + mention(f, f.bob) + ids := rowsFor(f, f.bob, "mention") + if len(ids) != 1 { + t.Fatalf("mention rows for bob = %v, want one from the real enqueue", ids) + } + if c := rowsFor(f, f.bob, "comment"); len(c) != 0 { + t.Fatalf("bob also has a comment row %v: a mention supersedes it", c) + } + age(f) + f.tick() + if st := f.state(ids[0]); st.State != "sent" { + t.Fatalf("mention row = %s/%q, want sent", st.State, st.Reason) + } + msgs := f.fake.all() + if len(msgs) != 2 { + t.Fatalf("%d messages, want one per device", len(msgs)) + } + cids := map[string]bool{} + for _, m := range msgs { + if m.Data.Kind != notify.KindMention || m.Data.NID != ids[0] || m.Body != "You were mentioned" { + t.Fatalf("message = %+v, want a mention with nid %s", m, ids[0]) + } + cids[m.Data.CID] = true + } + if len(cids) != 2 { + t.Fatalf("connection ids = %v, want one per device", cids) + } + }) + + t.Run("tombstoned_before_the_tick_is_comment_gone", func(t *testing.T) { + f := newPdx(t, srv) + cm := mention(f, f.bob) + ids := rowsFor(f, f.bob, "mention") + if len(ids) != 1 { + t.Fatalf("mention rows = %v", ids) + } + age(f) + f.exec(`UPDATE comments SET deleted_at = ? WHERE id = ?`, f.ts(time.Second), cm.ID) + f.tick() + if st := f.state(ids[0]); st.State != "skipped" || st.Reason != skipCommentGone { + t.Fatalf("mention row = %s/%q, want skipped/comment_gone", st.State, st.Reason) + } + if n := f.fake.callCount(); n != 0 { + t.Fatalf("gateway called %d times for a tombstoned mention", n) + } + }) + + t.Run("no_row_for_an_invisible_user_or_the_author", func(t *testing.T) { + f := newPdx(t, srv) + carol := f.user("carol") + must(t, srv.store.AddWorkspaceMember(f.ws.ID, carol.ID, "editor")) + f.device(carol, "tok-carol-"+fmt.Sprint(f.n), 5*time.Hour) + f.device(f.owner, "tok-owner-"+fmt.Sprint(f.n), 5*time.Hour) + must(t, srv.store.SetMemberCollectionAccess(f.ws.ID, carol.ID, "specific", []string{})) + mention(f, f.bob, carol, f.owner) + if n := len(rowsFor(f, f.bob, "mention")); n != 1 { + t.Fatalf("control: bob has %d mention rows, want 1", n) + } + if n := len(rowsFor(f, carol, "mention")); n != 0 { + t.Fatalf("carol cannot see the item yet has %d mention rows", n) + } + if n := len(rowsFor(f, f.owner, "mention")); n != 0 { + t.Fatalf("the author mentioned themselves and has %d mention rows", n) + } + }) + }) +} + +// A disabled account is refused by RequireAuth before the handler, for every +// id. Through the real router, with the user's own live session, a REAL +// notification id and an unknown id answer the same bytes (403 +// account_disabled): the refusal cannot tell them apart. +func TestNotificationEnrichment_DisabledUserGetsOneRefusalForEveryId(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + f := newPdx(t, srv) + tok := f.bobSession() + id := f.row("comment") + if rr := getNotif(srv, tok, id); rr.Code != http.StatusOK { + t.Fatalf("control: %d %s", rr.Code, rr.Body.String()) + } + f.exec(`UPDATE users SET disabled_at = ? WHERE id = ?`, f.ts(time.Minute), f.bob.ID) + real, unknown := getNotif(srv, tok, id), getNotif(srv, tok, "no-such-notification") + if real.Code != http.StatusForbidden || !strings.Contains(real.Body.String(), "account_disabled") { + t.Fatalf("disabled, real id: %d %s, want 403 account_disabled", real.Code, real.Body.String()) + } + sameBytes(t, "a disabled user's real id", real, unknown) + if strings.Contains(real.Body.String(), "SECRET") { + t.Fatalf("refusal leaks content: %s", real.Body.String()) + } + }) +} + +// An installed app authenticates with a padapp_ token, which opens the +// /api/app/v1 router only. Presented to this account-level route the token is +// not a credential at all: whatever the answer, a REAL notification id (a row +// of the app's bot) and an unknown id get identical bytes, and no content. +func TestNotificationEnrichment_AppTokenGetsOneAnswerForEveryId(t *testing.T) { + f := appAPIFixture(t, "read") + id := "n-app-real" + if _, err := f.srv.store.DB().Exec(f.srv.store.D().Rebind(`INSERT INTO user_notifications + (id, user_id, workspace_id, item_id, kind, actor_user_id, dedupe_key, created_at) + VALUES (?, ?, ?, ?, 'assigned', ?, ?, ?)`), + id, f.in.bot.ID, f.ws.ID, f.item.ID, f.in.bot.ID, "k-"+id, time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)); err != nil { + t.Fatal(err) + } + real := appGet(f.srv, "/api/v1/me/notifications/"+id, f.token) + unknown := appGet(f.srv, "/api/v1/me/notifications/no-such-notification", f.token) + if real.Code == http.StatusOK { + t.Fatalf("an app token resolved a notification: %s", real.Body.String()) + } + sameBytes(t, "an app token's real id", real, unknown) + t.Logf("app token on /api/v1/me/notifications/{id}: %d %s", real.Code, real.Body.String()) +} diff --git a/internal/server/testdata/compress_get_routes.txt b/internal/server/testdata/compress_get_routes.txt index 7ed9af00a..1d2ac75f5 100644 --- a/internal/server/testdata/compress_get_routes.txt +++ b/internal/server/testdata/compress_get_routes.txt @@ -57,6 +57,7 @@ compress /api/v1/mcp/tool-surface compress /api/v1/me/invitations compress /api/v1/me/needs compress /api/v1/me/notification-settings +compress /api/v1/me/notifications/{id} compress /api/v1/me/ui-dismissals compress /api/v1/me/workspace-tabs/ skip /api/v1/oauth/clients/{id}/public-info