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:
1 parent
f42720ac64
commit
17274bb868
4 files changed
+237
-14
No files matched your search
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user