fix(podcasts): generic media delete refuses episode rows (invariant 2)
The admin endpoint DELETE /api/media-items/:id predated podcasts and would delete any media row — including podcast episodes — if hit directly. Invariant 2 (absolute form, user decision 2026-10-30) says episode rows are removed ONLY by whole-feed removal: "the feed should match the XML until the entire feed is gone." The plan audit found this endpoint was the one remaining surface that could violate it: the poller, unsubscribe, and the admin file-deletion endpoints are files-only, and the hash-conflict merge can't see episodes (they never hash). The guard sits right after the GetMediaItem read and BEFORE the storage-down check, so a refused episode never touches storage resolution. Same segregation pattern as the generic edit path (D9/D10, UpdateMediaItem). Pinned by TestDeleteMediaItemPodcastGuard: admin DELETE of an episode → 409 with the row surviving; a book delete through the same endpoint → 204 control.
This commit is contained in:
1 parent
5fee8bfb4a
commit
f47734dd7d
2 files changed
+59
No files matched your search
@@ -423,6 +423,54 @@ func TestPodcastEditSegregation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- invariant 2, absolute form: the generic DELETE never touches
|
||||
// episode rows (only whole-feed removal does) ----
|
||||
|
||||
func TestDeleteMediaItemPodcastGuard(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
lib, _ := createPodcastsLibrary(t, setup, "test DeleteGuard Podcasts")
|
||||
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
|
||||
LibraryID: lib.ID,
|
||||
FeedUrl: "https://feed.invalid/delete-guard.xml",
|
||||
Title: "DeleteGuard Feed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ep := upsertTestEpisode(t, setup, feed, "delguard-1", "Delete Guard Episode", 1)
|
||||
epID := uuid.UUID(ep.ID.Bytes).String()
|
||||
|
||||
t.Run("admin_delete_of_episode_refused", func(t *testing.T) {
|
||||
code, body := podcastAuthed(t, setup, setup.Token, "DELETE", "/api/media-items/"+epID, "")
|
||||
assert.Equal(t, http.StatusConflict, code,
|
||||
"PIN (invariant 2, 2026-10-30): episode rows are removed ONLY by whole-feed removal — the generic delete must 409: %s", string(body))
|
||||
_, err := setup.DB.GetMediaItem(adminSeedCtx(t, setup), ep.ID)
|
||||
require.NoError(t, err, "episode row must survive the refused delete")
|
||||
})
|
||||
|
||||
t.Run("book_delete_still_works", func(t *testing.T) {
|
||||
// Control: the rejection is podcast-specific, not a broken route.
|
||||
libType, err := setup.DB.GetLibraryTypeByName(seedCtx(), "audiobooks")
|
||||
require.NoError(t, err)
|
||||
book, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||
LibraryID: func() pgtype.UUID {
|
||||
lib2, cerr := setup.DB.CreateLibrary(seedCtx(), database.CreateLibraryParams{
|
||||
Name: "test DeleteGuard Books",
|
||||
LibraryTypeID: libType.ID,
|
||||
})
|
||||
require.NoError(t, cerr)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(seedCtx(), lib2.ID) })
|
||||
return lib2.ID
|
||||
}(),
|
||||
Title: "Delete Control Book",
|
||||
FilePath: "control-" + uuid.New().String() + ".m4b",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
code, body := podcastAuthed(t, setup, setup.Token, "DELETE",
|
||||
"/api/media-items/"+uuid.UUID(book.ID.Bytes).String(), "")
|
||||
assert.Equal(t, http.StatusNoContent, code,
|
||||
"generic delete must stay intact for scanned media: %d %s", code, string(body))
|
||||
})
|
||||
}
|
||||
|
||||
// ---- download worker: state machine, idempotency, file layout ----
|
||||
|
||||
func TestPodcastDownloadWorker(t *testing.T) {
|
||||
|
||||
@@ -1881,6 +1881,17 @@ func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Invariant 2 (podcast plan §10, absolute form): episode rows are
|
||||
// removed ONLY when the whole feed is removed — the feed must match
|
||||
// its XML until then. Same segregation as the edit path (D9/D10):
|
||||
// episodes are fetcher-owned; delete FILES via the podcast surfaces
|
||||
// (feed page / admin podcast view) instead.
|
||||
if media.Source == "podcast_episode" {
|
||||
return c.JSON(http.StatusConflict, map[string]string{
|
||||
"error": "podcast episodes are removed only by removing the whole feed — delete the episode's files instead (feed page or admin podcast view)",
|
||||
})
|
||||
}
|
||||
|
||||
// Storage-down guard (see HandleBulkDelete): refuse while the library
|
||||
// storage is unreachable so the cascade can't destroy annotations for
|
||||
// a file that will re-import on the next scan.
|
||||
|
||||
Reference in new issue
Block a user