Files
bookhoard/cmd/server/tests/progress_service_test.go
T
John O'Keefe 431fec05ad feat(security): library visibility enforced at the source; per-library series; annotation ownership; admin user creation
The family-lens hardening: library_visibility was only enforced where
file bytes flowed (download/reader/OPDS) — metadata surfaces leaked
hidden libraries to any authenticated account, and registration was
open by default with no admin-side user creation.

- Series are grouped PER LIBRARY now (same name in two libraries =
  two entries: manga adaptations / LN splits no longer merge);
  /api/series aggregates only the requester's VISIBLE libraries and
  /api/series/books REQUIRES a visible library_id (breaking contract;
  web + app clients updated in the same change).
- /api/media-items, item detail, and the reader content endpoints
  (chapters/panels/outline/THUMBNAILS — page images leaked unguarded
  next to a gated GetPage) all enforce the visible set; resolveLibrary
  no longer honors a hand-typed library_id outside the visible set.
- Annotation/note/bookmark mutations (update/delete/tombstone, sync
  LWW paths incl. conflict resolution) carry ownership predicates:
  another account's bare row id is a 404, not a silent delete.
- Admin user creation: POST /api/auth/users (AdminMiddleware) + a
  Create User card in the admin Users UI — accounts stay creatable
  with self-service registration OFF, which is now the schema default
  (fresh installs; the first-admin bootstrap exception is unchanged).
- Tests: series contract re-pinned (per-library, visibility 403s),
  new ownership + admin-create integration suites, OPDS all=1 test
  now passes an explicit page size (shared-DB growth made the fresh
  books fall off page 1), device-cap helper switched to the admin
  endpoint.
2026-10-13 12:03:01 -04:00

838 lines
30 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func pFloat64(v float64) *float64 { return &v }
func pStr(v string) *string { return &v }
func pInt(v int) *int { return &v }
func pInt64(v int64) *int64 { return &v }
func doReq(t *testing.T, method, url string, body interface{}, token string) *http.Response {
t.Helper()
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
require.NoError(t, err)
bodyReader = bytes.NewBuffer(b)
}
req, err := http.NewRequest(method, url, bodyReader)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
return resp
}
func decodeJSON(t *testing.T, resp *http.Response) map[string]interface{} {
t.Helper()
var result map[string]interface{}
err := json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
return result
}
func progressURL(serverURL, mediaItemID string) string {
return serverURL + "/api/media-items/" + mediaItemID + "/progress"
}
func getFloatField(t *testing.T, data map[string]interface{}, field string) float64 {
t.Helper()
val, ok := data[field]
require.True(t, ok, "%s should be present in response", field)
require.NotNil(t, val, "%s should not be null", field)
f, ok := val.(float64)
require.True(t, ok, "%s should be a number, got %T: %v", field, val, val)
return f
}
func getStringField(t *testing.T, data map[string]interface{}, field string) string {
t.Helper()
val, ok := data[field]
require.True(t, ok, "%s should be present in response", field)
require.NotNil(t, val, "%s should not be null", field)
s, ok := val.(string)
require.True(t, ok, "%s should be a string, got %T: %v", field, val, val)
return s
}
func TestProgressWeb_AuthContexts(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
url := progressURL(setup.Server.URL, mediaItemID)
t.Run("unauthenticated PUT returns 401", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.5,
}, "")
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("unauthenticated GET returns 401", func(t *testing.T) {
resp := doReq(t, "GET", url, nil, "")
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("regular user PUT succeeds", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.3,
}, setup.RegularToken)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("regular user GET succeeds", func(t *testing.T) {
resp := doReq(t, "GET", url, nil, setup.RegularToken)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("admin PUT succeeds", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.7,
}, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("admin GET succeeds", func(t *testing.T) {
resp := doReq(t, "GET", url, nil, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("invalid media item ID returns 400", func(t *testing.T) {
badURL := setup.Server.URL + "/api/media-items/not-a-uuid/progress"
resp := doReq(t, "GET", badURL, nil, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("nonexistent media item GET returns 200 with empty", func(t *testing.T) {
fakeID := uuid.New().String()
fakeURL := progressURL(setup.Server.URL, fakeID)
resp := doReq(t, "GET", fakeURL, nil, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
func TestProgressWeb_MergePreservesFields(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
url := progressURL(setup.Server.URL, mediaItemID)
t.Run("second PUT with only percentage preserves epubcfi and chapter from first", func(t *testing.T) {
resp1 := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.3,
"epubcfi": "epubcfi(/6/4/2:first)",
"chapter": 2,
}, setup.Token)
defer resp1.Body.Close()
require.Equal(t, http.StatusOK, resp1.StatusCode)
resp2 := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.5,
}, setup.Token)
defer resp2.Body.Close()
require.Equal(t, http.StatusOK, resp2.StatusCode)
getResp := doReq(t, "GET", url, nil, setup.Token)
defer getResp.Body.Close()
require.Equal(t, http.StatusOK, getResp.StatusCode)
result := decodeJSON(t, getResp)
pct := getFloatField(t, result, "percentage")
assert.InDelta(t, 0.5, pct, 0.01)
epubcfi := getStringField(t, result, "epubcfi")
assert.Equal(t, "epubcfi(/6/4/2:first)", epubcfi, "epubcfi should be preserved from first save")
chapter := getFloatField(t, result, "chapter")
assert.Equal(t, float64(2), chapter, "chapter should be preserved from first save")
})
t.Run("web save preserves koreader character_offset", func(t *testing.T) {
mediaItemID2 := createTestMediaItemID(t, setup)
url2 := progressURL(setup.Server.URL, mediaItemID2)
mediaUUID, _ := uuid.Parse(mediaItemID2)
userID := getTestUserID(t, setup.DB)
ctx := context.Background()
charOffset := int64(15000)
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "koreader",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: pFloat64(0.45),
CharacterOffset: &charOffset,
Chapter: pInt(5),
DeviceType: "koreader",
DeviceName: "KOReader Test",
Broadcast: false,
})
require.NoError(t, err)
resp := doReq(t, "PUT", url2, map[string]interface{}{
"percentage": 0.5,
"epubcfi": "epubcfi(/6/4/2:10)",
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be preserved")
assert.Equal(t, int64(15000), progress.CharacterOffset.Int64)
assert.True(t, progress.Chapter.Valid, "chapter should be preserved")
assert.Equal(t, int32(5), progress.Chapter.Int32)
assert.InDelta(t, 0.5, progress.Percentage.Float64, 0.001)
assert.Equal(t, "web", progress.LastSyncSource.String)
})
}
func TestProgressWeb_EnrichmentComputesFields(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
url := progressURL(setup.Server.URL, mediaItemID)
mediaUUID, _ := uuid.Parse(mediaItemID)
userID := getTestUserID(t, setup.DB)
ctx := context.Background()
t.Run("character_offset computed from percentage when total_characters set", func(t *testing.T) {
_, err := setup.DBPool.Exec(ctx, "UPDATE media_items SET total_characters = $1 WHERE id = $2", int64(200000), mediaUUID)
require.NoError(t, err)
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.5,
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be computed from percentage")
assert.Equal(t, int64(100000), progress.CharacterOffset.Int64)
})
t.Run("GET returns enriched format_group and total_characters", func(t *testing.T) {
getResp := doReq(t, "GET", url, nil, setup.Token)
defer getResp.Body.Close()
require.Equal(t, http.StatusOK, getResp.StatusCode)
result := decodeJSON(t, getResp)
_, hasFormatGroup := result["format_group"]
assert.True(t, hasFormatGroup, "format_group should be present in GET response")
_, hasTotalChars := result["total_characters"]
assert.True(t, hasTotalChars, "total_characters should be present in GET response")
})
}
func TestProgressWeb_ConflictDetection(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
mediaUUID, _ := uuid.Parse(mediaItemID)
userID := getTestUserID(t, setup.DB)
ctx := context.Background()
t.Run("different sources with >1% diff within 5min creates conflict record", func(t *testing.T) {
pct1 := 0.3
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "koreader",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: &pct1,
DeviceType: "koreader",
DeviceName: "KOReader",
Broadcast: false,
})
require.NoError(t, err)
pct2 := 0.6
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: &pct2,
DeviceType: "kobo",
DeviceName: "Kobo",
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001)
assert.Equal(t, "kobo", progress.LastSyncSource.String)
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.NotEmpty(t, conflicts, "conflict should be recorded in sync_conflicts table")
assert.Equal(t, "progress", conflicts[0].ConflictType)
assert.True(t, conflicts[0].ResolutionStatus.Valid)
assert.Equal(t, "unresolved", conflicts[0].ResolutionStatus.String)
})
mediaItemID2 := createTestMediaItemID(t, setup)
mediaUUID2, _ := uuid.Parse(mediaItemID2)
t.Run("same source rapid saves create no conflict", func(t *testing.T) {
for _, pct := range []float64{0.1, 0.3, 0.5, 0.7} {
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "web",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: &pct,
DeviceType: "web",
DeviceName: "Web",
Broadcast: false,
})
require.NoError(t, err)
}
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.Empty(t, conflicts, "same-source saves should not create conflicts")
})
mediaItemID3 := createTestMediaItemID(t, setup)
mediaUUID3, _ := uuid.Parse(mediaItemID3)
t.Run("different sources with <1% diff creates no conflict", func(t *testing.T) {
pct1 := 0.5
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "koreader",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: &pct1,
DeviceType: "koreader",
DeviceName: "KOReader",
Broadcast: false,
})
require.NoError(t, err)
pct2 := 0.505
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: &pct2,
DeviceType: "kobo",
DeviceName: "Kobo",
Broadcast: false,
})
require.NoError(t, err)
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.Empty(t, conflicts, "small percentage diff should not create conflict")
})
}
func TestProgressWeb_KoboIntegration(t *testing.T) {
setup := setupTestServer(t)
ctx := context.Background()
userID := getTestUserID(t, setup.DB)
koboDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: "Test Kobo Progress",
DeviceType: "kobo",
DeviceIdentifier: "kobo-progress-test",
AuthToken: koboDeviceToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "Should create kobo device")
_ = device
t.Run("unauthenticated Kobo markup returns 401", func(t *testing.T) {
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/kobo/invalid-token/markup", map[string]interface{}{
"ReadingSync": []map[string]interface{}{
{"ContentId": uuid.New().String(), "PercentRead": 50.0},
},
}, "")
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
mediaItemID := createTestMediaItemID(t, setup)
t.Run("ReadingSync then last-read-place preserves percentage", func(t *testing.T) {
readingSyncBody := map[string]interface{}{
"ReadingSync": []map[string]interface{}{
{
"ContentId": mediaItemID,
"PercentRead": 55.0,
},
},
}
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), readingSyncBody, "")
defer resp.Body.Close()
io.ReadAll(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
time.Sleep(100 * time.Millisecond)
bookmarkBody := map[string]interface{}{
"BookmarkSync": []map[string]interface{}{
{
"ContentId": mediaItemID,
"BookmarkId": "epubcfi(/6/4!/4/2/1:0)",
"BookmarkType": "last-read-place",
"Chapter": 5,
},
},
}
resp2 := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
defer resp2.Body.Close()
io.ReadAll(resp2.Body)
assert.Equal(t, http.StatusOK, resp2.StatusCode)
mediaUUID, _ := uuid.Parse(mediaItemID)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.True(t, progress.Percentage.Valid)
assert.InDelta(t, 0.55, progress.Percentage.Float64, 0.01, "percentage should still be 55% from ReadingSync")
})
t.Run("Kobo last-read-place without prior ReadingSync sets epubcfi and chapter", func(t *testing.T) {
newMediaID := createTestMediaItemID(t, setup)
newMediaUUID, _ := uuid.Parse(newMediaID)
bookmarkBody := map[string]interface{}{
"BookmarkSync": []map[string]interface{}{
{
"ContentId": newMediaID,
"BookmarkId": "epubcfi(/6/14!/4/2/1:0)",
"BookmarkType": "last-read-place",
"Chapter": 3,
},
},
}
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
defer resp.Body.Close()
io.ReadAll(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: newMediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.True(t, progress.Epubcfi.Valid, "epubcfi should be set from last-read-place")
assert.True(t, progress.Chapter.Valid, "chapter should be set from last-read-place")
assert.Equal(t, int32(3), progress.Chapter.Int32)
})
}
func TestProgressWeb_KOReaderIntegration(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
userID := getTestUserID(t, setup.DB)
ctx := context.Background()
koreaderDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
_, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: "Test KOReader Progress",
DeviceType: "koreader",
DeviceIdentifier: "koreader-progress-test",
AuthToken: koreaderDeviceToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "Should create koreader device")
t.Run("KOReader progress sync via HTTP", func(t *testing.T) {
progressBody := map[string]interface{}{
"books": []map[string]interface{}{
{
"file_path": "/tmp/test.epub",
"percentage": 0.42,
"chapter": 3,
"device_info": map[string]interface{}{
"device_model": "Test Device",
"koreader_version": "1.0",
},
},
},
}
bodyBytes, _ := json.Marshal(progressBody)
req, err := http.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/progress", bytes.NewBuffer(bodyBytes))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+koreaderDeviceToken)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
io.ReadAll(resp.Body)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
mediaUUID, _ := uuid.Parse(mediaItemID)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
if err == nil {
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
}
})
t.Run("unauthenticated KOReader sync returns 401", func(t *testing.T) {
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/koreader/progress", map[string]interface{}{
"books": []map[string]interface{}{
{"file_path": "/tmp/test.epub", "percentage": 0.5},
},
}, "")
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
func TestProgressWeb_DeleteProgress(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
url := progressURL(setup.Server.URL, mediaItemID)
t.Run("DELETE removes progress", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.75,
"epubcfi": "epubcfi(/6/4/2:20)",
"chapter": 7,
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
delResp := doReq(t, "DELETE", url, nil, setup.Token)
defer delResp.Body.Close()
assert.Equal(t, http.StatusOK, delResp.StatusCode)
getResp := doReq(t, "GET", url, nil, setup.Token)
defer getResp.Body.Close()
result := decodeJSON(t, getResp)
assert.Equal(t, float64(0), result["current_page"], "progress should be cleared after delete")
})
t.Run("DELETE without auth returns 401", func(t *testing.T) {
resp := doReq(t, "DELETE", url, nil, "")
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
func TestProgressWeb_EdgeCases(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
url := progressURL(setup.Server.URL, mediaItemID)
t.Run("PUT with empty body still succeeds", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{}, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("PUT percentage 0.0", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.0,
}, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
getResp := doReq(t, "GET", url, nil, setup.Token)
defer getResp.Body.Close()
require.Equal(t, http.StatusOK, getResp.StatusCode)
})
t.Run("PUT percentage 1.0", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 1.0,
}, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("PUT with all fields then GET verifies each", func(t *testing.T) {
mediaItemID2 := createTestMediaItemID(t, setup)
url2 := progressURL(setup.Server.URL, mediaItemID2)
mediaUUID2, _ := uuid.Parse(mediaItemID2)
userID := getTestUserID(t, setup.DB)
ctx := context.Background()
resp := doReq(t, "PUT", url2, map[string]interface{}{
"percentage": 0.42,
"current_page": 84,
"total_pages": 200,
"epubcfi": "epubcfi(/6/4!/4/2/1:0)",
"chapter": 3,
"chapter_progress": 0.5,
"character_offset": 15000,
"reading_mode": "page",
"zoom_level": 1.5,
"scroll_position_x": 0.0,
"scroll_position_y": 100.0,
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.True(t, progress.Percentage.Valid)
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
assert.True(t, progress.Chapter.Valid)
assert.Equal(t, int32(3), progress.Chapter.Int32)
assert.True(t, progress.Epubcfi.Valid)
assert.Equal(t, "epubcfi(/6/4!/4/2/1:0)", progress.Epubcfi.String)
assert.True(t, progress.CurrentPage.Valid)
assert.Equal(t, int32(84), progress.CurrentPage.Int32)
assert.True(t, progress.TotalPages.Valid)
assert.Equal(t, int32(200), progress.TotalPages.Int32)
assert.True(t, progress.ReadingMode.Valid)
assert.Equal(t, "page", progress.ReadingMode.String)
assert.True(t, progress.ZoomLevel.Valid)
assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
})
}
// TestDeletedUserTokenRejected pins the ghost-session guard: a
// signature-valid JWT for a user that has been DELETED must stop
// authenticating (401 user_not_found), not serve a phantom session.
// Before the guard, deleted users kept full API access until token
// expiry (observed live on hardware 2026-10-09).
func TestDeletedUserTokenRejected(t *testing.T) {
setup := setupTestServer(t)
ctx := context.Background()
// Create a dedicated user, then log in as it to get a fresh token.
// Admin-created (not self-registered) so the test stays deterministic
// regardless of the registration_enabled setting.
createResp := doReq(t, "POST", setup.Server.URL+"/api/auth/users", map[string]interface{}{
"email": "ghost-test@tests.bookhoard.internal",
"username": "ghosttest",
"password": "Ghost@Pass123!",
}, setup.Token)
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Skipf("admin user creation unavailable (status %d)", createResp.StatusCode)
}
loginResp := doReq(t, "POST", setup.Server.URL+"/api/auth/login", map[string]interface{}{
"login": "ghost-test@tests.bookhoard.internal",
"password": "Ghost@Pass123!",
}, "")
defer loginResp.Body.Close()
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var loginBody struct {
AccessToken string `json:"access_token"`
}
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody))
require.NotEmpty(t, loginBody.AccessToken)
ghostToken := loginBody.AccessToken
// Sanity: the token works while the user exists (user-scoped endpoint
// a freshly-registered regular user can access).
okResp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
defer okResp.Body.Close()
require.Equal(t, http.StatusOK, okResp.StatusCode, "token must work while the user exists")
// Delete the user directly via the DB for determinism (same query the
// suite's own cleanup uses).
ghostID := ""
users, err := setup.DB.ListUsers(ctx)
require.NoError(t, err)
for _, u := range users {
if u.Email == "ghost-test@tests.bookhoard.internal" {
ghostID = uuid.UUID(u.ID.Bytes).String()
require.NoError(t, setup.DB.DeleteUser(ctx, u.ID))
}
}
require.NotEmpty(t, ghostID, "ghost user row must exist before deletion")
// The same token must now be rejected with 401 user_not_found.
resp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "ghost token must be rejected")
var errBody map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody))
assert.Contains(t, errBody["message"], "user_not_found")
}
// TestProgressWeb_LocatorCoherence pins the locator coherence rule: a
// percentage-only write that DECLARES no locator (ClearLocator) must clear
// any inherited stale anchor instead of re-pairing the new percentage with
// the previous writer's CFI/context — the incoherent row shape that fed the
// client-side far-anchor restore floor. Request-carried fields always win.
func TestProgressWeb_LocatorCoherence(t *testing.T) {
setup := setupTestServer(t)
ctx := context.Background()
userID := getTestUserID(t, setup.DB)
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
seedAnchor := func(t *testing.T, mediaItemID string) uuid.UUID {
mediaUUID, _ := uuid.Parse(mediaItemID)
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "web",
Percentage: pFloat64(0.3),
Epubcfi: pStr("epubcfi(/6/4/2:first)"),
ContextText: pStr("some anchor context sentence"),
DeviceType: "web",
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
require.True(t, progress.Epubcfi.Valid, "seed: anchor stored")
return mediaUUID
}
t.Run("percentage-only kobo write clears the inherited anchor", func(t *testing.T) {
mediaItemID := createTestMediaItemID(t, setup)
mediaUUID := seedAnchor(t, mediaItemID)
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "kobo",
Percentage: pFloat64(0.6),
DeviceType: "kobo",
ClearLocator: true,
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001, "percentage updated")
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared, not re-paired")
assert.False(t, progress.ContextText.Valid, "stale context cleared")
assert.Equal(t, "kobo", progress.LastSyncSource.String)
})
t.Run("request-carried context survives the clear", func(t *testing.T) {
mediaItemID := createTestMediaItemID(t, setup)
mediaUUID := seedAnchor(t, mediaItemID)
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "koreader",
Percentage: pFloat64(0.7),
ContextText: pStr("a brand new context sentence"),
DeviceType: "koreader",
ClearLocator: true,
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.InDelta(t, 0.7, progress.Percentage.Float64, 0.001)
assert.True(t, progress.ContextText.Valid, "request-carried context kept")
assert.Equal(t, "a brand new context sentence", progress.ContextText.String)
// The verify gate is inert in the test harness (no media path
// resolver wired), so no anchor can be minted here; the rule
// under test is that the STALE one is gone.
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared")
})
t.Run("HTTP merge semantics untouched: key-absent PUT preserves fields", func(t *testing.T) {
mediaItemID := createTestMediaItemID(t, setup)
mediaUUID := seedAnchor(t, mediaItemID)
// Service-level save WITHOUT ClearLocator mimics the HTTP merge
// path (field absent = inherit): the anchor must survive.
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "queue",
Percentage: pFloat64(0.4),
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.True(t, progress.Epubcfi.Valid, "merge path preserves the anchor")
assert.Equal(t, "epubcfi(/6/4/2:first)", progress.Epubcfi.String)
})
}