Files
pad/internal/server/handlers_push_store_error_test.go
xarmian 5003718802 fix(push): apply delivery's visibility gate to delivered_sessions (BUG-2725) (#1187)
deliveredSessionCount applied three of watchNotificationVisible's four
gates, missing the first thing delivery checks: vis.allows(CollectionID,
ItemID). Broadcast over-reported. Targeted was worse — the publish-skip
reads this count, so the gate passed, the push went out, the stream
dropped it on visibility, and the response said delivered_sessions: 1.
An instruction lost behind a success.

Per Dave's day-49 ruling, visibility is RE-RESOLVED at push time rather
than snapshotted: membership and grants are revocable, so a value cached
at connect goes wrong exactly when revocation is what makes it matter.

The one input that cannot be re-resolved is the target connection's auth
transport — computeWatchAccessVisibility consults isBearerAuth exactly
once, inside the admin bypass, and the pushing request only knows its
own. So SessionOrigin.BearerAuth is recorded at Add(). That is NOT the
snapshot the ruling rejected: auth transport is a property of the
connection, fixed when it opened and not revocable while held, so it
cannot go stale. Armed is the precedent. SessionOrigin is kept separate
from SessionIdentity because that type documents itself as self-declared
and never verified; folding a server-derived security fact in there
would silently retract the warning for one field. Both comments state
the rule for future extenders: connection properties are admissible,
derived authorization state never is.

computeWatchAccessVisibility now takes a bool instead of an
*http.Request, which makes the per-connection input visible in the
signature and lets the count answer for a connection it is not serving.

COST: "re-resolve per counted session" reads like N access checks per
push. It is at most TWO, and sessionVisibility's memo makes that true by
construction rather than by careful calling — every other input is
per-user and identical across the sessions counted, so one varying
boolean bounds the answers at two. Pinned by a test with 50 sessions.

Codex round 1 (P1): the first version swallowed store errors into "not
visible", reintroducing BUG-2698 through this fix — a targeted push
reporting 0 SKIPS the publish, so a DB blip would drop the instruction
and answer 200, in a function whose own doc comment says why 0 is
load-bearing. Round 2 (P1): the same class one layer down —
computeWatchAccessVisibility collapsed FOUR store failures into a
denial, two discarded into underscores. Fixed as a class per CONVE-18.
Resolution and policy are now separate: stream-side callers discard the
error explicitly with reasons, only the counting caller propagates.
Round 3 CLEAN.

CONVE-23 sweep found three consumer-facing artifacts still describing
the old mechanism, none on a line this diff touched: the plugin skill
doc, the web push dialog, and pad push --help. All three corrected to
name what actually remains rather than deleting the caveat. Plugin
0.3.1 -> 0.3.2, since installed plugins are version-pinned at install.

NOT fixed, deliberately: the UNDER-count. A stream past
maxSessionsPerUser receives broadcasts while never entering the
registry. delivered_sessions remains an estimate with error in both
directions, and every consumer-facing description now says so.

Two coverage gaps recorded rather than rounded off: mutation M11
survives (the reporting test reaches only the first of four store calls,
because closing the DB fails it first), and no test drives the whole
chain store-fault-to-503 (the DB-close instrument kills the request
earlier, so such a test would have gone green against the wrong 500 —
deleted rather than relaxed).

Also lands the BUG-2752 refutation sentinel: that item claimed the OAuth
workspace allow-list went unenforced on /api/v1/events/stream. Refuted —
no allow-list-bearing credential can authenticate to /api/v1/* at all.
The test guards that format gate, so if it ever widens, the refutation's
premise fails loudly instead of silently reopening a leak.

Gates on the merged tip: make test 27 pkgs, make lint 0 issues, full
Postgres suite 27 pkgs, govulncheck, codex CLEAN, CI 7/7.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 09:45:48 -04:00

93 lines
4.1 KiB
Go

package server
import (
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// GAP, RECORDED RATHER THAN PAPERED OVER (codex round 2).
//
// There is no test here driving the WHOLE chain — store fault →
// computeWatchAccessVisibility → pushSessionVisibility →
// deliveredSessionCount → handler 503 — with a real store failure.
//
// The attempt is worth recording because its failure is instructive. The
// only fault instrument available at this layer is closing the store's
// database, and with the DB closed the request dies far earlier, in
// workspace resolution: the handler answers 500 "Failed to resolve
// workspace" and the visibility code never runs. A test asserting
// "not 200" would have gone green against that 500 and measured the
// middleware while claiming to measure this fix — the precise shape of a
// test that passes for a reason unrelated to the thing it names. So it
// was deleted rather than relaxed.
//
// What IS covered, link by link: computeWatchAccessVisibility reports
// store failures (below, with a healthy-store control);
// deliveredSessionCount propagates them
// (TestDeliveredSessionCount_PropagatesVisibilityError, verified to fail
// when the error is swallowed); and the handler's error → 503-for-
// targeted mapping is pre-existing, tested behaviour
// (TestPushToItem_TargetedWithUnreadablePresenceIsRefused). Every link
// is pinned; the seam between them is argued, not measured. Closing it
// needs a finer fault injector than this package currently has.
// TestComputeWatchAccessVisibility_ReportsStoreFailure pins the reporting
// contract directly, at the layer that owns it, and pins the other half
// of it too: the visibility returned ALONGSIDE an error must be the
// deny-everything value.
//
// That second assertion is what makes the two deliberate `_` discards
// (filterWatchesByCurrentAccess and watchVisCache.forWorkspace) safe. If
// an error were ever returned next to a permissive visibility, those
// call sites would silently start granting access on failure — the
// opposite of the fail-closed posture they document.
//
// SECOND GAP, MEASURED NOT GUESSED. This reaches only the FIRST of the
// function's four store calls. Closing the DB makes GetWorkspaceMember
// fail immediately, so the function returns before GuestVisibleResources,
// GetMemberCollectionAccess or ListSystemCollectionIDs ever run — and a
// mutation re-swallowing GetMemberCollectionAccess's error survives this
// test (verified, matrix entry M11). The later three are propagated by
// inspection, not by measurement.
//
// Reaching them needs the earlier calls to SUCCEED while a later one
// fails, which this package cannot express today: srv.store is a
// concrete *store.Store, so there is no seam to wrap with a
// fail-the-Nth-call double. Recorded as the honest state rather than
// described as full coverage; closing it is a store-seam change, larger
// than this unit.
func TestComputeWatchAccessVisibility_ReportsStoreFailure(t *testing.T) {
srv := testServerWithWatchEvents(t)
slug := createWSWithCollections(t, srv)
ws, err := srv.store.GetWorkspaceBySlug(slug)
if err != nil {
t.Fatalf("GetWorkspaceBySlug: %v", err)
}
user, err := srv.store.CreateUser(models.UserCreate{
Email: "vis-store-err@example.com", Name: "User", Password: "pw-test-12345",
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
// Control: a healthy store reports no error.
if _, err := srv.computeWatchAccessVisibility(true, user, ws.ID); err != nil {
t.Fatalf("control leg: healthy store must not report an error: %v", err)
}
if err := srv.store.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
vis, err := srv.computeWatchAccessVisibility(true, user, ws.ID)
if err == nil {
t.Fatal("an unreadable store resolved silently to a visibility; the failure must be " +
"reported so a counting caller can distinguish it from a genuine denial")
}
if vis.fullAccess || len(vis.visibleCollIDs) > 0 || len(vis.grantedItemIDs) > 0 {
t.Fatalf("visibility returned alongside an error must be deny-everything, got %+v — "+
"the call sites that discard the error rely on exactly that", vis)
}
}