feat(notification): add persistent notification

This commit is contained in:
Edouard Vanbelle
2026-09-11 22:08:35 +02:00
parent a6138aa4d9
commit 617ae4b424
32 changed files with 1911 additions and 28 deletions
+141 -1
View File
@@ -64,6 +64,39 @@
# unit tests; the seeded suite has no
# admin token, and minting one here
# would pollute state for other files.
# S13 Notification wire push — user2 subscribes to
# `user:{user2_id}:notifications`; user1
# creates a grant that targets user2.
# Server must publish one `rt.event`
# with `event="notification_received"`,
# `data.kind="share_granted"`. Guards
# the Slice-E ingester + the auto-sub
# delivery path together — this is the
# only scenario that exercises the
# wire push from the `NotificationService`.
# S14 Notification DB row — after S13's grant, GET
# `/api/notifications` as user2 lists
# at least one row with
# `kind="share_granted"` whose payload
# references the freshly-shared folder,
# and `unread_count >= 1`. Guards the
# authoritative side of the pattern —
# a subscriber offline at publish time
# recovers via this endpoint.
# S15 Cross-user notif deny — user1 subscribes to
# `user:{user2_id}:notifications`
# (an identity-scoped topic that
# resolves to somebody else). Server
# must reject with `topic_forbidden`
# (same wire shape as unknown topic,
# same rule as S9 for `:authz`).
# Guards the strict-privacy Class-2
# AuthZ gate on Slice-E notifications
# — no admin bypass, direct UUID
# equality only. If this ever accepts
# and delivers events, an admin (or
# anyone else) could snoop on other
# users' notification streams.
#
# Exit non-zero on any failure — run.sh treats that as a suite failure.
# ─────────────────────────────────────────────────────────────────────────────
@@ -679,4 +712,111 @@ if ! "$HELPER_BIN" expect-denied \
fi
log "S12 OK"
log "All twelve message-bus scenarios passed."
# ── Scenario 13 — Notification wire push (Slice E) ──────────────────────────
# The `share_granted` ingester runs in `grant_handler::create_grant`:
# after `set_role` lands and before the email path, it calls
# `NotificationService::create` for every resolved recipient user.
# `create` writes the DB row AND publishes a thin
# `MessageBusEvent::NotificationReceived` on
# `user:{user_id}:notifications`. This scenario exercises the wire
# path end-to-end: user2 opens a WS + explicitly subscribes to their
# own notifications topic (idempotent with the server's auto-sub),
# user1 fires a fresh grant, user2 sees the one event.
#
# A fresh folder C is used so this scenario is independent of the
# S8 grant/revoke sequence — user2 already has DB rows from S8's
# grants on A + B, but those events fired BEFORE user2's WS opened
# so no wire delivery competes with S13's.
log "S13: create folder C, subscribe user2 to their notifications, expect one share_granted event."
folder_c=$(c_post "$base_url/api/folders" "$user1_token" \
"$(printf '{"name":"rt_bus_C_%s","parent_id":"%s"}' "$suffix" "$root_id")" | jq -r '.id')
[[ -n "$folder_c" && "$folder_c" != "null" ]] || die "S13: folder C creation failed"
out_s13="$(mktemp -t rtbus_s13.XXXXXX)"
ready_s13="$(mktemp -t rtbus_s13_ready.XXXXXX)"; rm -f "$ready_s13"
"$HELPER_BIN" subscribe-and-collect \
--url "$ws_url" \
--token "$user2_token" \
--subscribe "user:${user2_id}:notifications" \
--expect-events 1 \
--timeout 5s \
--ready-file "$ready_s13" \
--output "$out_s13" &
helper_pid=$!
wait_ready "$ready_s13"
grant_c=$(c_post "$base_url/api/grants" "$user1_token" \
"$(printf '{"subject":{"type":"user","id":"%s"},"resource":{"type":"folder","id":"%s"},"role":"viewer"}' \
"$user2_id" "$folder_c")")
grant_c_id=$(printf '%s' "$grant_c" | jq -r '.grants[0].id')
[[ -n "$grant_c_id" && "$grant_c_id" != "null" ]] \
|| die "S13: grant on folder C failed: $grant_c"
if ! wait "$helper_pid"; then
cat "$out_s13" >&2 || true
die "S13: helper did not observe the notification_received event"
fi
[[ "$(jq -r '.events | length' "$out_s13")" == "1" ]] \
|| { cat "$out_s13"; die "S13: expected 1 event, got $(jq -r '.events | length' "$out_s13")"; }
[[ "$(jq -r '.events[0].event' "$out_s13")" == "notification_received" ]] \
|| die "S13: wrong event discriminator: $(jq -r '.events[0].event' "$out_s13")"
[[ "$(jq -r '.events[0].data.kind' "$out_s13")" == "share_granted" ]] \
|| die "S13: wrong notification kind: $(jq -r '.events[0].data.kind' "$out_s13")"
# `notification_id` is a fresh UUID stamped by the DB — check it's
# non-empty and non-null. Value asserted by S14 via GET /api/notifications.
[[ -n "$(jq -r '.events[0].data.notification_id' "$out_s13")" ]] \
&& [[ "$(jq -r '.events[0].data.notification_id' "$out_s13")" != "null" ]] \
|| die "S13: notification_id missing on wire payload"
log "S13 OK"
# ── Scenario 14 — Notification DB row (Slice E) ─────────────────────────────
# The bus event is best-effort. The DB row is truth: a subscriber
# offline at publish time recovers via `GET /api/notifications`.
# S13 fired a grant on folder C; the ingester wrote a row for user2.
# This scenario reads it back and asserts on shape.
#
# `unread_count` from the same response reflects ALL unread rows,
# including the 2 from S8's grants (folders A + B) — the fresh grant
# in S13 brings the total to >= 3. We assert >= 1 (loose enough to
# not couple to S8's state, tight enough to prove the row landed).
log "S14: GET /api/notifications as user2; expect a share_granted row for folder C."
notifs=$(c_get "$base_url/api/notifications" "$user2_token")
unread=$(printf '%s' "$notifs" | jq -r '.unread_count')
[[ "$unread" -ge 1 ]] \
|| { printf '%s\n' "$notifs" >&2; die "S14: unread_count expected >= 1, got $unread"; }
# Filter for the S13 row: kind == share_granted AND payload.resource_id == folder_c.
match_count=$(printf '%s' "$notifs" | jq --arg fc "$folder_c" \
'[.items[] | select(.kind == "share_granted" and .payload.resource_id == $fc)] | length')
[[ "$match_count" -ge 1 ]] \
|| { printf '%s\n' "$notifs" >&2; die "S14: no share_granted row for folder C (matches=$match_count)"; }
# The matched row must be unread (read_at is null) — the caller
# hasn't clicked it yet, so the bell would still badge it.
first_read_at=$(printf '%s' "$notifs" | jq -r --arg fc "$folder_c" \
'first(.items[] | select(.kind == "share_granted" and .payload.resource_id == $fc)) | .read_at')
[[ "$first_read_at" == "null" ]] \
|| die "S14: matched row unexpectedly marked read: read_at=$first_read_at"
log "S14 OK"
# ── Scenario 15 — Cross-user notifications identity gate ────────────────────
# `Topic::UserNotifications(u)` maps to `AuthzCheck::IdentityMatch{u}`
# in `application/ports/message_bus_ports.rs::required_perm`. Direct
# UUID equality only — no admin bypass. A caller subscribing to
# another user's notifications channel MUST be denied with the same
# wire shape (`topic_forbidden`) as an unknown topic — anti-enum.
#
# If this ever regresses (identity check dropped, engine wired on
# this class, admin bypass added) it becomes a privacy leak on par
# with an admin snooping on `:authz` streams. Same guard as S9,
# different topic suffix.
log "S15: user1 subscribes to user:{user2_id}:notifications; expect topic_forbidden."
if ! "$HELPER_BIN" expect-denied \
--url "$ws_url" \
--token "$user1_token" \
--subscribe "user:${user2_id}:notifications" \
--reason topic_forbidden \
--timeout 3s; then
die "S15: user1 was NOT denied on user2's notifications topic (identity gate broken?)"
fi
log "S15 OK"
log "All fifteen message-bus scenarios passed."