package main import ( "bytes" "testing" "encoding/json" "fmt" "net/http" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Collections RLS (2026-10-31): ownership enforcement moved to the DB // layer (p_collections_* + parent-checked collection_items policies in // schema.sql, predicates app_can_read/write_collection). The API surface // answers invisibility with 404 — including the write endpoints that // had NO app-level gate (Update/AddBooks, the flagged gap). Admin keeps // the escape; system collections stay per-user; registration still // seeds them (the seeding identity fix in // CreateDefaultCollectionsForUser is regression-pinned here). func TestCollectionsRLS(t *testing.T) { setup := setupTestServer(t) ownerToken := setup.RegularToken // user A: the harness regular user adminToken := setup.Token marker := fmt.Sprintf("rls%d", time.Now().UnixNano()%100000) // User B: a second regular user via registration. The register // endpoint is rate-limited — exactly one call. The response's // access_token is B's identity for every probe below. regEmail := fmt.Sprintf("%s@tests.bookhoard.internal", marker) regBody := fmt.Sprintf( `{"email":%q,"username":%q,"password":"Test@Pass123!"}`, regEmail, "rlsb"+marker) regResp, err := http.Post(setup.Server.URL+"/api/auth/register", "application/json", bytes.NewReader([]byte(regBody))) require.NoError(t, err) require.Equal(t, http.StatusCreated, regResp.StatusCode, "registration must succeed — including the system-collection seeding that now runs under the new user's RLS identity") var reg struct { Token string `json:"access_token"` } require.NoError(t, json.NewDecoder(regResp.Body).Decode(®)) require.NotEmpty(t, reg.Token) otherToken := reg.Token _ = regResp.Body.Close() // B's registration seeded B's OWN system collections — visible in // B's list (this is the registration-seeding identity fix, live). code, body := podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "") require.Equal(t, http.StatusOK, code) assert.Contains(t, string(body), "Continue Reading", "registration seeded the new user's system collections under RLS") // A creates the target collection. code, body = podcastAuthed(t, setup, ownerToken, "POST", "/api/collections", `{"name":"test RLS Owner ` + marker + `"}`) require.Equal(t, http.StatusCreated, code, "%s", string(body)) var coll struct { ID string `json:"id"` } require.NoError(t, json.Unmarshal(body, &coll)) t.Cleanup(func() { podcastAuthed(t, setup, ownerToken, "DELETE", "/api/collections/"+coll.ID, "") }) // ── B (non-owner) probes A's collection: invisible = 404 ── code, _ = podcastAuthed(t, setup, otherToken, "GET", "/api/collections/"+coll.ID, "") require.Equal(t, http.StatusNotFound, code, "detail-by-id is RLS-invisible cross-user") code, body = podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "") require.Equal(t, http.StatusOK, code) assert.NotContains(t, string(body), "test RLS Owner "+marker, "the list query stays user-scoped") code, body = podcastAuthed(t, setup, otherToken, "PUT", "/api/collections/"+coll.ID, `{"name":"renamed by B"}`) require.Equal(t, http.StatusNotFound, code, "update of an RLS-invisible row answers 404 (was: ungated success): %s", string(body)) code, body = podcastAuthed(t, setup, otherToken, "POST", "/api/collections/"+coll.ID+"/books", `{"book_ids":[]}`) require.Equal(t, http.StatusNotFound, code, "AddBooks on an RLS-invisible collection answers 404 (the flagged ungated-write gap, closed at the DB)") code, body = podcastAuthed(t, setup, otherToken, "PUT", "/api/collections/"+coll.ID+"/view-settings", `{"chain_order":"newest"}`) require.Equal(t, http.StatusNotFound, code, "chain-order write on an invisible collection answers 404") code, _ = podcastAuthed(t, setup, otherToken, "DELETE", "/api/collections/"+coll.ID, "") require.Equal(t, http.StatusNoContent, code, "DELETE touches no rows under RLS (silent no-op — no destructive action)") // The no-op delete lied about nothing: A still owns the collection. code, body = podcastAuthed(t, setup, ownerToken, "GET", "/api/collections/"+coll.ID, "") require.Equal(t, http.StatusOK, code, "owner retains the collection after B's failed delete") // ── admin escape: sees everything, as with media ── code, _ = podcastAuthed(t, setup, adminToken, "GET", "/api/collections/"+coll.ID, "") require.Equal(t, http.StatusOK, code, "admin persona sees collections (policy escape)") // ── B's ownership works: B renames B's OWN system collection ── // (positive control — B is not locked out of what B owns) code, body = podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "") require.Equal(t, http.StatusOK, code) var list struct { Collections []struct { ID string `json:"id"` Name string `json:"name"` } `json:"collections"` } require.NoError(t, json.Unmarshal(body, &list)) var bSystem string for _, c := range list.Collections { if c.Name == "Continue Reading" { bSystem = c.ID break } } require.NotEmpty(t, bSystem, "B owns the seeded system collections") code, body = podcastAuthed(t, setup, otherToken, "PUT", "/api/collections/"+bSystem, `{"name":"Continue Reading"}`) require.Contains(t, []int{http.StatusOK, http.StatusCreated}, code, "owner mutation of an OWN collection still works: %s", string(body)) }