fix(podcasts): download_all binds on every wire — FlexBool + flip log
Reproduced the §0 item 2 suspect: echo v5 bool-binds form values via strconv.ParseBool, which accepts "true"/"1" but REJECTS the browser-native <input type=checkbox> value "on" — and a bind failure kills the WHOLE AddFeed request (400 "invalid request"), not just the checkbox. The add modal posts urlencoded (htmx), so any checkbox-serialization that emits "on" made Download All silently unusable. Fix: FlexBool (encoding.TextUnmarshaler-style UnmarshalParam) binds download_all permissively on every wire — "true"/"on"/"yes"/"checked" => true, explicit false/0/off => false, ABSENCE (unchecked box on any wire) => false; JSON bool binding is native and unaffected. Tolerates the exact wires htmx and browsers emit. AddFeed's subscription flip now logs "[PODCAST] flip ... <feed> — user" so the live flip is provable in app logs (user suspected it never fired). Integration test TestPodcastDownloadAllFormEncoding pins the urlencoded wire (what htmx actually sends): true + on both flip the caller's policy to 'all', an unchecked add leaves the default.
This commit is contained in:
1 parent
cdf81f6cf4
commit
7c7c08748e
2 files changed
+130
-12
No files matched your search
@@ -11,6 +11,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/url"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -733,6 +734,22 @@ func TestPodcastPageSearchEpisodes(t *testing.T) {
|
||||
|
||||
// ---- issue #3: Download All flips the CALLER's auto-download to all ----
|
||||
|
||||
// podcastAuthedForm posts urlencoded (what htmx actually sends for non-GET —
|
||||
// the bug class in §0 item 2: the add modal posts a form, not JSON).
|
||||
func podcastAuthedForm(t *testing.T, setup *TestServerSetup, token, method, path string, form url.Values) (int, []byte) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(method, setup.Server.URL+path,
|
||||
strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(resp.Body)
|
||||
return resp.StatusCode, buf.Bytes()
|
||||
}
|
||||
|
||||
func TestPodcastDownloadAllPolicyFlip(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
regularID := getRegularUserID(t, setup.DB)
|
||||
@@ -807,6 +824,84 @@ func TestPodcastDownloadAllPolicyFlip(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- §0 item 2: download_all via the WIRE the add modal uses (urlencoded) ----
|
||||
|
||||
func TestPodcastDownloadAllFormEncoding(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
lib, _ := createPodcastsLibrary(t, setup, "test FormEnc Podcasts")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
_, _ = w.Write([]byte(podcastFixture("FormEnc Feed", map[string]string{"fe-1": "FormEnc One"})))
|
||||
})
|
||||
mux.HandleFunc("/feed2.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
_, _ = w.Write([]byte(podcastFixture("FormEnc Default Feed", map[string]string{"fd-1": "FormEnc Default One"})))
|
||||
})
|
||||
mux.HandleFunc("/feed3.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
_, _ = w.Write([]byte(podcastFixture("FormEnc On Feed", map[string]string{"fo-1": "FormEnc On One"})))
|
||||
})
|
||||
srv := swapFeedServer(t, mux.ServeHTTP)
|
||||
|
||||
t.Run("urlencoded_add_with_download_all_true_flips_policy", func(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
|
||||
form.Set("url", srv.URL+"/feed.xml")
|
||||
form.Set("download_all", "true") // what the add modal's value="true" checkbox submits
|
||||
resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form)
|
||||
require.Equal(t, http.StatusCreated, resp, string(body))
|
||||
|
||||
ignore := assertPodcastPolicy(t, setup, "FormEnc Feed", srv.URL+"/feed.xml")
|
||||
assert.Equal(t, "all", ignore,
|
||||
"PIN: urlencoded download_all=true binds exactly like JSON — htmx posts forms, not JSON")
|
||||
})
|
||||
|
||||
t.Run("urlencoded_add_with_browser_native_on_also_flips", func(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
|
||||
form.Set("url", srv.URL+"/feed3.xml")
|
||||
form.Set("download_all", "on") // the raw <input type=checkbox> value — echo's ParseBool chokes on it and failed the WHOLE bind (400) before the FlexBool fix
|
||||
resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form)
|
||||
require.Equal(t, http.StatusCreated, resp, string(body))
|
||||
|
||||
assert.Equal(t, "all", assertPodcastPolicy(t, setup, "FormEnc On Feed", srv.URL+"/feed3.xml"))
|
||||
})
|
||||
|
||||
t.Run("urlencoded_add_without_download_all_keeps_default", func(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
|
||||
form.Set("url", srv.URL+"/feed2.xml")
|
||||
resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form)
|
||||
require.Equal(t, http.StatusCreated, resp, string(body))
|
||||
|
||||
assert.Equal(t, "off", assertPodcastPolicy(t, setup, "FormEnc Default Feed", srv.URL+"/feed2.xml"),
|
||||
"PIN: an UNchecked add must NOT flip the policy (absent field ≠ true)")
|
||||
})
|
||||
}
|
||||
|
||||
// assertPodcastPolicy returns the regular user's auto_download for the
|
||||
// subscribed feed identified by (title, url).
|
||||
func assertPodcastPolicy(t *testing.T, setup *TestServerSetup, title, feedURL string) string {
|
||||
t.Helper()
|
||||
regularID := getRegularUserID(t, setup.DB)
|
||||
regularPG := pgtype.UUID{Bytes: regularID, Valid: true}
|
||||
feeds, _ := setup.DB.ListPodcastFeedsForPolling(rls.WithUser(context.Background(), regularPG))
|
||||
var feedID pgtype.UUID
|
||||
for _, f := range feeds {
|
||||
if f.Title == title && f.FeedUrl == feedURL {
|
||||
feedID = f.ID
|
||||
}
|
||||
}
|
||||
require.True(t, feedID.Valid, "subscribed feed %q (%s) must exist", title, feedURL)
|
||||
sub, err := setup.DB.GetPodcastSubscription(rls.WithUser(context.Background(), regularPG), database.GetPodcastSubscriptionParams{
|
||||
UserID: regularPG, FeedID: feedID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return sub.AutoDownload
|
||||
}
|
||||
|
||||
// ---- logout actually signs out (fake-signout bug, 2026-10-30) ----
|
||||
|
||||
func TestLogoutClearsSession(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user