fix(nas): refuse deletes while library storage is unreachable; real file removal; stat cache

Three changes from the network-drive audit:

1. Delete-ordering fix. Deleting a media item cascades its annotations,
   so the delete must never proceed while the item's library storage is
   unreachable — the DB row died, the file survived as an orphan, and
   the next scan re-imported it bare (annotations gone forever).
   resolveMediaStorage stats the library's folders: reachable → delete
   proceeds and the file is removed by ABSOLUTE path (the old code
   passed a library-relative path to os.Remove, which never resolved —
   file deletion has been a silent no-op); every folder unreachable →
   409 with 'storage unreachable', row and annotations intact.

2. Latency: resolveLibrary stats every library folder on every SSR
   render; folder reachability is now cached with a 30s TTL.

3. README: Network Storage section documenting the outage hysteresis
   (mark → archive → auto-restore), the 90-day retention purge
   (ARCHIVE_RETENTION_DAYS, 0 = never), refused deletes while storage
   is down, and watch mode not firing on network mounts.

Integration tests: storage-down bulk delete refuses with the row
intact; real-file delete removes the file from disk.
This commit is contained in:
John O'Keefe committed 2026-10-14 09:00:08 -04:00
1 parent f42720ac64
commit 17274bb868
4 files changed
+237 -14

No files matched your search

+91 -8
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
@@ -24,15 +26,19 @@ import (
// registration disabled.
type OwnershipAndAdminTestSuite struct {
suite.Suite
setup *TestServerSetup
token string
itemID string
setup *TestServerSetup
token string
itemID string
libraryID string
absFile string
}
func (s *OwnershipAndAdminTestSuite) SetupSuite() {
s.setup = setupTestServer(s.T())
s.token = s.setup.Token
s.itemID = s.createItemViaDB()
var libID string
s.itemID, libID, s.absFile = s.createItemViaDB(true)
s.libraryID = libID
// createTestMediaItemID's t.Cleanup path can't be used at suite level
// (it fires after TearDownSuite has closed the server), so the item is
// created directly through the DB handle; suite cleanup relies on the
@@ -42,7 +48,10 @@ func (s *OwnershipAndAdminTestSuite) SetupSuite() {
// createItemViaDB makes a throwaway library + media item directly through
// the test DB handle — no HTTP, no t.Cleanup entanglement.
func (s *OwnershipAndAdminTestSuite) createItemViaDB() string {
// With withFolder=true the library gets a REAL temp folder row and the
// file is created on disk inside it, so deletes exercise real file
// removal; the folder path is returned alongside the item id.
func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, libraryID, absFile string) {
ctx := s.setup.QueueCtx
db := s.setup.DB
@@ -53,23 +62,39 @@ func (s *OwnershipAndAdminTestSuite) createItemViaDB() string {
require.NoError(s.T(), err, "dev admin user should exist")
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Ownership Suite Library %d", time.Now().UnixNano()),
Name: fmt.Sprintf("Ownership Suite Test Library %d", time.Now().UnixNano()),
Description: pgtype.Text{String: "annotation ownership suite", Valid: true},
LibraryTypeID: libraryTypeRow.ID,
CreatedByAdminID: admin.ID,
})
require.NoError(s.T(), err)
relPath := fmt.Sprintf("ownership-suite-%d.epub", time.Now().UnixNano())
absFile = ""
if withFolder {
dir, dirErr := os.MkdirTemp("", "ownership-suite-*")
require.NoError(s.T(), dirErr)
_, ferr := db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: library.ID,
FolderPath: dir,
})
require.NoError(s.T(), ferr)
absFile = filepath.Join(dir, relPath)
if werr := os.WriteFile(absFile, []byte("restored book"), 0o644); werr != nil {
require.NoError(s.T(), werr)
}
}
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Ownership Suite Item",
Author: pgtype.Text{String: "Suite Author", Valid: true},
FilePath: fmt.Sprintf("/tmp/ownership-suite-%d.epub", time.Now().UnixNano()),
FilePath: relPath,
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(s.T(), err)
return uuid.UUID(item.ID.Bytes).String()
return uuid.UUID(item.ID.Bytes).String(), uuid.UUID(library.ID.Bytes).String(), absFile
}
func (s *OwnershipAndAdminTestSuite) TearDownSuite() {
@@ -227,6 +252,64 @@ func (s *OwnershipAndAdminTestSuite) TestAdminCreateUser() {
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
}
// TestDelete_StorageDownRefused pins the NAS guard: deleting a media item
// while its library storage is unreachable is REFUSED (row + annotations
// survive) instead of cascading annotations for a file that will re-import
// bare on the next scan.
func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() {
ctx := s.setup.QueueCtx
db := s.setup.DB
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(s.T(), err)
admin, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(s.T(), err)
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Ownership Suite Test Offline %d", time.Now().UnixNano()),
Description: pgtype.Text{String: "storage-down suite", Valid: true},
LibraryTypeID: libraryTypeRow.ID,
CreatedByAdminID: admin.ID,
})
require.NoError(s.T(), err)
// Folder row pointing at a mount that does not exist.
_, err = db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: library.ID,
FolderPath: "/nonexistent-mount-xyz",
})
require.NoError(s.T(), err)
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Offline Storage Item",
FilePath: "offline.epub",
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(s.T(), err)
itemID := uuid.UUID(item.ID.Bytes).String()
payload, _ := json.Marshal(map[string]interface{}{"media_item_ids": []string{itemID}})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/media-items/bulk-delete", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.token)
resp, err := (&http.Client{}).Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode, "bulk endpoint answers 200 with per-item errors")
var body map[string]interface{}
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
results := body["results"].([]interface{})
require.Len(s.T(), results, 1)
first := results[0].(map[string]interface{})
assert.Contains(s.T(), first["error"], "storage unreachable")
// The row survived the refused delete.
check, err := db.GetMediaItem(ctx, item.ID)
require.NoError(s.T(), err)
assert.Equal(s.T(), "Offline Storage Item", check.Title)
}
func TestOwnershipAndAdminTestSuite(t *testing.T) {
suite.Run(t, new(OwnershipAndAdminTestSuite))
}