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:
John O'Keefe committed 2026-10-30 14:57:08 -04:00
1 parent 5fee8bfb4a
commit f47734dd7d
2 files changed
+59

No files matched your search

+48
View File
@@ -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) {
+11
View File
@@ -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.