Files
bookhoard/cmd/server/tests/rls_visibility_test.go
T
John O'Keefe 8c3d919aa0 wip(rls): engine-side fail-loud + harness seed identity [pin: work interrupted]
Design correction discovered by the auth chicken-and-egg: the
decorator-level refusal was re-created a convention gate (plan B
thinking). ScopedDB now NEVER refuses — it declares the GUC when the
context carries an identity and declares nothing otherwise; the ENGINE
is the fail-loud layer (policies raise on governed tables, ungoverned
tables like auth lookups simply work). rls.WithInternal removed as a
dead concept.

- search service's raw DBTX path now receives the ScopedDB itself
  (main.go + harness) — a bare pool bypassed the GUC.
- database/schema.go: migration tx declares a synthetic identity
  (schema.sql's dedup DO block SELECTs media_items; with policies
  live, boot 2 raised 'unrecognized configuration parameter' — A11).
- harness: TestServerSetup.DB is a raw-pool SEED handle (write
  policies permissive, other seeded tables ungoverned); the server
  under test keeps the scoped wiring. 33 CreateMediaItem sites use
  seedCtx() — a seed identity with no visibility rows — because
  RETURNING requires SELECT visibility of the new row.
- invariant test: regex UUID-scraping replaced with a structured JSON
  decode of the sections payload.

INTERRUPTION NOTE: an edit to this same effect (seedCtx) was applied,
confirmed by the tool, and subsequently vanished from disk while an
'opencode serve' process (PID 84551) is alive on the machine. This
commit pins the tree against further loss; implementation is PAUSED
pending the user killing that process.
2026-10-29 08:44:12 -04:00

284 lines
11 KiB
Go

package main
// rls_visibility_test.go — the PERMANENT black-box auditor for per-user
// library visibility (the "family lens").
//
// The one sentence it asserts, in the user's language:
//
// A user with a library hidden sees NOTHING from that library, on
// ANY media-returning surface — while an admin still sees it (the
// hide is per-user, never global).
//
// Implementation-agnostic on purpose: it speaks only HTTP + response
// bodies, so it stays valid regardless of HOW enforcement is done
// (per-query visible_library_ids clauses today, Postgres RLS in the
// RLS rollout, anything tomorrow). It is the auditor that makes a
// forgotten surface a failing test instead of a silent leak.
//
// Red/green history: RED at introduction on the dashboard-section and
// collection-detail (mobile View All) surfaces, whose queries never
// carried the visible_library_ids clause; GREEN once enforcement
// covers every surface (plan:
// ~/.opencode/plan/server-rls-library-visibility.md).
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
// uuidInBody finds candidate collection ids in a dashboard-sections
// body so the View-All detail endpoint can be probed without coupling
// the test to unrelated ids in the body.
func TestRlsInvariant(t *testing.T) {
setup := setupTestServer(t)
ctx := context.Background()
adminToken := setup.Token
regularToken := setup.RegularToken
regularID := getRegularUserID(t, setup.DB)
// Unique markers: one for the hidden library's whole subtree
// (library name + both book titles — a single string that must
// appear NOWHERE), one for the visible control book.
hiddenMarker := fmt.Sprintf("RLSHIDE%s", uuid.New().String()[:8])
controlMarker := fmt.Sprintf("RLSCTRL%s", uuid.New().String()[:8])
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(t, err, "ebooks library type should exist")
createLib := func(name string) database.Libraries {
lib, cerr := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: name,
Description: pgtype.Text{String: "rls invariant test library", Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
})
require.NoError(t, cerr, "create library %q", name)
// Deleting the library cascades its media_items AND its
// library_visibility rows (FK ON DELETE CASCADE) — one cleanup
// covers the whole subtree.
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
return lib
}
libH := createLib("RLS Test Hidden Library " + hiddenMarker)
libV := createLib("RLS Test Visible Library " + controlMarker)
createBook := func(libraryID pgtype.UUID, title string) {
_, cerr := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libraryID,
Title: title,
Authors: []string{"RLS Invariant Author"},
FilePath: fmt.Sprintf("/tmp/rls-invariant/%s-%s.epub", title, uuid.New().String()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, cerr, "create book %q", title)
}
createBook(libH.ID, hiddenMarker+" Hidden Alpha")
createBook(libH.ID, hiddenMarker+" Hidden Beta")
createBook(libV.ID, controlMarker+" Visible Gamma")
// Hide libH from the REGULAR user — the same row the admin
// visibility UI writes (harness idiom: SetLibraryVisibility).
_, err = setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
LibraryID: libH.ID,
IsVisible: false,
})
require.NoError(t, err, "hide libH from regular user")
// OPDS device owned by the REGULAR user (OPDS identity = device owner).
opdsToken := "dev_" + uuid.New().String()
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
DeviceName: "RLS Invariant OPDS Device",
DeviceType: "koreader",
DeviceIdentifier: "rls-invariant-" + uuid.New().String()[:8],
AuthToken: opdsToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "create OPDS device for regular user")
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, device.ID) })
rlsGet := func(token, path string) (int, string) {
req, _ := http.NewRequest(http.MethodGet, setup.Server.URL+path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err, "GET %s", path)
defer func() { _ = resp.Body.Close() }()
var sb strings.Builder
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
sb.Write(buf[:n])
}
if err != nil {
break
}
}
return resp.StatusCode, sb.String()
}
// Surfaces that render media TITLES (books appear by name).
titleSurfaces := []struct {
name string
path string
auth string
}{
{"media-items list", "/api/media-items?limit=500", regularToken},
{"dashboard sections", "/api/dashboard/sections?limit=500", regularToken},
{"opds catalog", "/opds/devices/" + device.ID.String() + "/catalog", opdsToken},
}
// Surfaces that return media-adjacent data (counts, series names,
// aggregates) — hidden-subtree leakage would show up as marker
// series names / inventory rows, not book titles.
aggregateSurfaces := []struct {
name string
path string
auth string
}{
{"collections list", "/api/collections", regularToken},
{"series list", "/api/series", regularToken},
{"stats summary", "/api/stats/summary", regularToken},
{"stats library-usage", "/api/stats/library-usage", regularToken},
}
t.Run("restricted_user_hidden_library_absent_everywhere", func(t *testing.T) {
for _, s := range titleSurfaces {
t.Run(s.name, func(t *testing.T) {
code, body := rlsGet(s.auth, s.path)
require.Equal(t, http.StatusOK, code, "surface %s must be authorized (a 401 here is a harness bug, not a pass)", s.path)
// THE INVARIANT: nothing from the hidden subtree —
// neither its books nor the library itself.
assert.NotContains(t, body, hiddenMarker,
"LEAK: hidden library content surfaced on %s", s.path)
// Control: the visible library's book IS present —
// guards against false greens from empty/anonymous
// responses.
if s.name != "opds catalog" { // root catalog may nest books behind library subsections
assert.Contains(t, body, controlMarker,
"control book missing from %s — response is not listing media (false-green guard)", s.path)
}
})
}
for _, s := range aggregateSurfaces {
t.Run(s.name, func(t *testing.T) {
code, body := rlsGet(s.auth, s.path)
require.Equal(t, http.StatusOK, code, "surface %s must be authorized", s.path)
assert.NotContains(t, body, hiddenMarker,
"LEAK: hidden library content surfaced on %s", s.path)
})
}
})
t.Run("restricted_user_dashboard_scoped_to_hidden_library", func(t *testing.T) {
code, body := rlsGet(regularToken, "/api/dashboard/sections?library_id="+uuid.UUID(libH.ID.Bytes).String())
assert.NotContains(t, body, hiddenMarker,
"LEAK: dashboard scoped to the hidden library leaked its content (status %d)", code)
})
t.Run("restricted_user_collection_detail_view_all", func(t *testing.T) {
// The mobile View All path: GET /api/collections/{id} for every
// collection the dashboard references (system sections included).
// Structured decode — probe exactly the collection ids the
// sections payload declares (its own contract field), not
// whatever UUIDs happen to appear in the body.
_, sectionsBody := rlsGet(regularToken, "/api/dashboard/sections?limit=500")
var sections struct {
Sections []struct {
CollectionID string `json:"collection_id"`
} `json:"sections"`
}
require.NoError(t, json.Unmarshal([]byte(sectionsBody), &sections),
"dashboard sections should decode; body: %s", sectionsBody)
seen := map[string]bool{}
for _, sec := range sections.Sections {
id := sec.CollectionID
if id == "" || seen[id] {
continue
}
seen[id] = true
code, body := rlsGet(regularToken, "/api/collections/"+id)
if code != http.StatusOK {
continue // not visible to this user — absence trivially holds
}
assert.NotContains(t, body, hiddenMarker,
"LEAK: collection detail %s (View All surface) returned hidden-library content", id)
}
})
t.Run("restricted_user_explicit_hidden_library_scoped_requests_rejected", func(t *testing.T) {
// Pinned contract (AGENTS.md family-lens rule): naming a hidden
// library explicitly is a 403, never a silent pass.
libID := uuid.UUID(libH.ID.Bytes).String()
for _, path := range []string{
"/api/media-items?library_id=" + libID,
"/api/series?library_id=" + libID,
"/api/series/books?library_id=" + libID + "&name=RLS",
} {
code, body := rlsGet(regularToken, path)
assert.Equal(t, http.StatusForbidden, code,
"scoped request %s must be 403 for a hidden library (got %d)", path, code)
assert.NotContains(t, body, hiddenMarker)
}
})
t.Run("admin_still_sees_hidden_library_user_scoped_not_global", func(t *testing.T) {
// The hide is PER-USER: an admin (no hidden rows) must still see
// everything. This catches "fixing" the leak by hiding the
// library globally.
for _, s := range []struct{ name, path string }{
{"media-items list", "/api/media-items?limit=500"},
{"dashboard sections", "/api/dashboard/sections?limit=500"},
} {
t.Run(s.name, func(t *testing.T) {
code, body := rlsGet(adminToken, s.path)
require.Equal(t, http.StatusOK, code)
assert.Contains(t, body, hiddenMarker,
"admin must still see the hidden library on %s (hide is user-scoped)", s.path)
})
}
t.Run("opds catalog", func(t *testing.T) {
// OPDS identity = device owner; an ADMIN-owned device sees all.
adminDevToken := "dev_" + uuid.New().String()
adminID := getTestUserID(t, setup.DB)
adminDevice, derr := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(adminID), Valid: true},
DeviceName: "RLS Invariant Admin OPDS Device",
DeviceType: "koreader",
DeviceIdentifier: "rls-invariant-admin-" + uuid.New().String()[:8],
AuthToken: adminDevToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, derr, "create OPDS device for admin")
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, adminDevice.ID) })
code, body := rlsGet(adminDevToken, "/opds/devices/"+adminDevice.ID.String()+"/catalog")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, body, hiddenMarker,
"admin's OPDS catalog must still expose the hidden library (user-scoped hide)")
})
})
}