Files
bookhoard/cmd/server/tests/collections_smart_test.go
T
John O'Keefe ab98015190 feat(collections): RLS ownership enforcement at the DB layer
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).
2026-10-31 18:30:02 -04:00

309 lines
12 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
// HTTP helpers -----------------------------------------------------------
func smartCreateCollection(t *testing.T, setup *TestServerSetup, token, name string, collectionType string) string {
t.Helper()
payload := map[string]interface{}{
"name": name,
"description": "smart collections test",
"color": "blue",
"icon": "📚",
"collection_type": collectionType,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode, "collection creation should succeed")
var result map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
return result["id"].(string)
}
func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionID string) map[string]interface{} {
t.Helper()
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections/"+collectionID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
require.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
return result
}
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
t.Helper()
ctx := context.Background()
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(t, err)
uniqueName := fmt.Sprintf("Test Smart Rules Library %d", time.Now().UnixNano())
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: uniqueName,
Description: pgtype.Text{String: "smart collections test library", Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
})
require.NoError(t, err, "should create test library")
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
require.NoError(t, err)
t.Cleanup(func() {
_ = setup.DB.DeleteLibrary(ctx, library.ID)
})
for title, genre := range titlesByGenre {
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: title,
Authors: []string{"Smart Rule Author"},
Genre: pgtype.Text{String: genre, Valid: true},
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err, "should create test media item")
mediaUUID, err := uuid.FromBytes(media.ID.Bytes[0:16])
require.NoError(t, err)
bookIDs = append(bookIDs, mediaUUID.String())
}
return libUUID.String(), bookIDs
}
func smartLibraryTitleSet(t *testing.T, coll map[string]interface{}) map[string]bool {
t.Helper()
books, _ := coll["books"].([]interface{})
set := make(map[string]bool, len(books))
for _, b := range books {
if m, ok := b.(map[string]interface{}); ok {
if title, ok := m["title"].(string); ok {
set[title] = true
}
}
}
return set
}
// Tests ------------------------------------------------------------------
func TestSmartCollections(t *testing.T) {
setup := setupTestServer(t)
adminID := getTestUserID(t, setup.DB)
t.Run("TreeCollectionLiveMembership", func(t *testing.T) {
// The unified engine (item 3.2): a collection created WITH a
// books-scope tree evaluates LIVE — stored links are ignored,
// only tree matches render.
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart SF Book One": "Science Fiction",
"Smart SF Book Two": "Science Fiction",
"Smart FANT Book": "Fantasy",
})
_ = bookIDs
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart Tree " + uuid.NewString()[:8],
"filter_tree": tree,
})
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
_ = resp.Body.Close()
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+created.ID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
detail := smartGetCollection(t, setup, setup.Token, created.ID)
assert.Equal(t, true, detail["is_smart"], "tree presence derives the type")
memberTitles := smartLibraryTitleSet(t, detail)
assert.True(t, memberTitles["Smart SF Book One"])
assert.True(t, memberTitles["Smart SF Book Two"])
assert.False(t, memberTitles["Smart FANT Book"], "the tree's AND governs membership live")
})
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart Guard Book": "Science Fiction",
})
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart Guard " + uuid.NewString()[:8],
"filter_tree": tree,
})
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
_ = resp.Body.Close()
collID := created.ID
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
addReq.Header.Set("Content-Type", "application/json")
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
addResp, err := http.DefaultClient.Do(addReq)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, addResp.StatusCode)
_ = addResp.Body.Close()
removeReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/books/"+uuid.NewString(), nil)
removeReq.Header.Set("Authorization", "Bearer "+setup.Token)
removeResp, err := http.DefaultClient.Do(removeReq)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, removeResp.StatusCode)
_ = removeResp.Body.Close()
})
t.Run("SystemCollectionsBlockedFromTheEditor", func(t *testing.T) {
// System collections may never carry rules/trees — the editor
// route refuses them (item 3.4: the page IS the editor).
colls, err := setup.DB.GetCollectionsByUser(ownerSeedCtx(pgtype.UUID{Bytes: [16]byte(adminID), Valid: true}), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
require.NoError(t, err)
var systemID string
for _, c := range colls {
if c.IsSystemCollection.Bool {
systemID = uuid.UUID(c.ID.Bytes).String()
break
}
}
require.NotEmpty(t, systemID, "admin user should have system collections")
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+systemID+"/rules", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
"system collections never render the smart editor")
})
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart List Book": "Science Fiction",
})
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart List " + uuid.NewString()[:8],
"filter_tree": tree,
})
req2, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+setup.Token)
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp2.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&created))
_ = resp2.Body.Close()
collID := created.ID
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
// Default list (the Android app's read): smart collections hidden.
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
defaultResp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(defaultResp.Body)
var defaultData struct {
Collections []struct {
ID string `json:"id"`
} `json:"collections"`
}
require.NoError(t, json.NewDecoder(defaultResp.Body).Decode(&defaultData))
for _, c := range defaultData.Collections {
assert.NotEqual(t, collID, c.ID, "default listing excludes rule-driven collections (app compat)")
}
// Opt-in listing includes them with LIVE counts.
scopeReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections?include_auto=true", nil)
scopeReq.Header.Set("Authorization", "Bearer "+setup.Token)
scopeResp, err := http.DefaultClient.Do(scopeReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(scopeResp.Body)
var scopeData struct {
Collections []struct {
ID string `json:"id"`
BookCount int `json:"book_count"`
IsSmart bool `json:"is_smart"`
} `json:"collections"`
}
require.NoError(t, json.NewDecoder(scopeResp.Body).Decode(&scopeData))
found := false
for _, c := range scopeData.Collections {
if c.ID == collID {
found = true
assert.True(t, c.IsSmart)
assert.Equal(t, 1, c.BookCount, "smart card counts rule matches live")
}
}
assert.True(t, found, "include_auto=true must surface smart collections")
})
}