collections + collection_items join the RLS-governed tables, same doctrine as the media_items family lens (2026-10-29). Policies route through two PURE predicate functions (app_can_read_collection / app_can_write_collection) that never query collections — no policy recursion under FORCE RLS — so a future sharing model is one EXISTS branch inside the functions, not a schema re-touch. Reads: owner or admin; writes: owner, admin escape for SYSTEM collections only. Why: the write path had no app-level ownership gate on Update/Delete/ AddBooks — any authenticated user could mutate another user's collection by id. The DB is now the enforcement of record; existing app-level gates (match-mode, reorder, view-settings) stay as defense-in-depth. Behavior changes (contract_pins 2026-10-31): - cross-user GET detail: 200 -> 404 (row invisible) - cross-user PUT update: silent success -> 404 (ErrNoRows mapping; service wrap fixed %v -> %w so errors.Is sees pgx.ErrNoRows) - cross-user POST books: silent success -> 404 - cross-user DELETE: 204, now a true DB no-op - anonymous sees nothing; admin persona unchanged Bootstrap: registration seeds system collections BEFORE the JWT exists — CreateDefaultCollectionsForUser declares the new user's identity (rls.WithUser), the same fix mirrored in the test harness (createDefaultCollectionsForUser owner-scoped; collection sweeps run under the owner's identity). Tests: new TestCollectionsRLS pins the cross-user matrix end-to-end (404 detail/update/add/view-settings, 204 no-op delete, admin escape, owner positive control, registration seeding under RLS). Harness fixture writes to non-system collections now run as the row's OWNER (scopeExecAs/ownerSeedCtx) — the admin write-escape covers system rows only, and bare-ctx pool reads of collections see nothing. Filter/playlist/audiobook/scan-state fixtures converted. Full container suite green (159s).
128 lines
5.3 KiB
Go
128 lines
5.3 KiB
Go
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))
|
|
}
|