test(podcasts): integration suite + RLS auditor + contract widening (task 1.10)
cmd/server/tests/podcast_test.go — six suites, the shared-dev-DB doctrine (test*-named libraries, cascading cleanup, no binary fixtures): - TestPodcastRLSSurfaces: THE AUDITOR (invariant 13) — non-subscriber 404 on feed detail + SSR page (existence leak), episodes absent from generic media reads, admin escape, subscriber visibility, then the hidden-library flip hiding an already-subscribed feed (the feed policy's library-visibility join). Seeding runs as the FIRST ADMIN — the seed identity has neither subscription nor users row, so podcast INSERT..RETURNING (SELECT policy under FORCE) 42501s for it; production pods run as exactly that admin (invariant 12). All direct row probes read through the admin scope for the same reason. - TestPodcastAddFeedFlow: member add + subscribe + metadata-only upsert; duplicate-URL dedupe (no second row); allow_member_feeds gate (member 403 / admin 201 / subscribe-still-allowed). - TestPodcastShrunkenFeedRetention: feed purges an episode from the XML, both rows persist (upsert-without-delete pin, D14). - TestPodcastEditSegregation: generic edit 400 + write-to-file refusal for episodes; control book edit unaffected. - TestPodcastDownloadWorker (ffmpeg-generated tone at runtime): file lands at <download-root>/<feed-slug>/NNNN-…, audio_metadata probed; re-download of a downloaded episode never re-fetches (idempotency pin, enclosure-hit counted); dead enclosure → failed + persisted reason + metadata intact. Fetch/SSRF tests swap the client via SetPodcastHTTPClientForTests (the production client refuses loopback httptest servers by design — the real client gets its own negative test in services). - TestPodcastScannerExclusion: SetFolders on a podcasts library root yields zero scanner folders (invariant 4 pin) — MediaScanner .FolderCount() accessor added. Unit: internal/services/podcast_rss_test.go — namespace-aware parsing fixture (three XML namespaces, fallback chains, hosts/guests), duration spellings, RFC-822 variants, explicit variants, slug/path layout, enclosure-extension selection, stripHTML, and the SSRF matrix (private/loopback/link-local/multicast/mapped-v6 blocked; a loopback httptest server PROVES the production client refuses it). suites green: full integration run 161s / 0 failures; vitest 12 files 163 tests. Contract widening: TestContractPin_MediaListBothBranches' raw-row allowlist gains the 15 podcast episode columns (additive; documented in docs/contract_pins.md — inert defaults on scan rows). Bruno: bruno/ podcasts/ mirrors the §8 endpoints (12 requests) + Bookhoard env vars. tests/ui/COVERAGE.md rows 18-23: podcasts pages/lifecycle/RLS/chain — Playwright sweep pending.
This commit is contained in:
1 parent
28420d56ed
commit
49b0b6eeb9
24 files changed
+1510
-10
No files matched your search
@@ -17,6 +17,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
@@ -113,17 +114,18 @@ type PodcastItemXML struct {
|
||||
} `xml:"person"`
|
||||
}
|
||||
|
||||
// ParsePodcastFeed decodes a feed document. Returns an error when the
|
||||
// XML is unparseable or the document is not an RSS channel.
|
||||
// ParsePodcastFeed decodes a feed document via a namespace-aware token
|
||||
// decoder (see decodePodcastFeed). Returns an error when the XML is
|
||||
// unparseable or the document is not an RSS channel.
|
||||
func ParsePodcastFeed(data []byte) (*PodcastFeedXML, error) {
|
||||
var feed PodcastFeedXML
|
||||
if err := xml.Unmarshal(data, &feed); err != nil {
|
||||
feed, err := decodePodcastFeed(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("feed parse: %w", err)
|
||||
}
|
||||
if feed.Channel.Title == "" && len(feed.Channel.Items) == 0 {
|
||||
return nil, fmt.Errorf("feed parse: not an RSS channel (no title, no items)")
|
||||
}
|
||||
return &feed, nil
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
// EpisodeTitle prefers itunes:title over the raw RSS title (plan §5).
|
||||
@@ -255,3 +257,304 @@ func parseFeedTime(v string) (t time.Time, ok bool) {
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Namespace-aware decoding.
|
||||
//
|
||||
// WHY NOT encoding/xml: its field matching for UNQUALIFIED tags ignores
|
||||
// namespaces — a field tagged `xml:"title"` also matches
|
||||
// `<itunes:title>` (first field in declaration order wins, document
|
||||
// order clobbers), so the two collide unpredictably. RSS feeds stack
|
||||
// core RSS, iTunes and Podcasting-2.0 namespaces over the SAME local
|
||||
// names (title, author, description, guid), so a token-loop decoder
|
||||
// dispatching on (namespace, local) is the only deterministic shape.
|
||||
// It also tolerates BOTH podcast-namespace URIs the ecosystem uses and
|
||||
// arbitrary prefix spellings, because matching is on resolved
|
||||
// namespace, never on the prefix string.
|
||||
// ============================================================================
|
||||
|
||||
// decodePodcastFeed runs the token loop and fills the document struct.
|
||||
func decodePodcastFeed(data []byte) (*PodcastFeedXML, error) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
dec.Strict = false // feeds in the wild carry stray entities; be liberal
|
||||
|
||||
feed := &PodcastFeedXML{}
|
||||
// Find the <rss> start element (tolerate comments/PIs/doctype).
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if se, ok := tok.(xml.StartElement); ok {
|
||||
if se.Name.Local == "rss" {
|
||||
break
|
||||
}
|
||||
// Some feeds wrap in nothing else; only rss is accepted.
|
||||
return nil, fmt.Errorf("unexpected root element %q", se.Name.Local)
|
||||
}
|
||||
}
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Local == "channel" {
|
||||
if err := decodeChannel(dec, &feed.Channel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := skipElement(dec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case xml.EndElement:
|
||||
// </rss> — done.
|
||||
return feed, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodeChannel walks the channel's children.
|
||||
func decodeChannel(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
ns, local := t.Name.Space, t.Name.Local
|
||||
switch {
|
||||
case ns == "" && local == "title":
|
||||
ch.Title, err = elementText(dec)
|
||||
case ns == "" && local == "description":
|
||||
ch.Description, err = elementText(dec)
|
||||
case ns == "" && local == "language":
|
||||
ch.Language, err = elementText(dec)
|
||||
case ns == "" && local == "link":
|
||||
ch.Link, err = elementText(dec)
|
||||
case ns == "" && local == "lastBuildDate":
|
||||
ch.LastBuildDate, err = elementText(dec)
|
||||
case ns == itunesNS && local == "author":
|
||||
ch.ITunesAuthor, err = elementText(dec)
|
||||
case ns == itunesNS && local == "owner":
|
||||
err = decodeITunesOwner(dec, ch)
|
||||
case ns == itunesNS && local == "image":
|
||||
ch.ITunesImage.Href = attrValue(t.Attr, "href")
|
||||
err = skipElement(dec)
|
||||
case ns == itunesNS && local == "explicit":
|
||||
ch.ITunesExplicit, err = elementText(dec)
|
||||
case ns == itunesNS && local == "type":
|
||||
ch.ITunesType, err = elementText(dec)
|
||||
case ns == itunesNS && local == "new-feed-url":
|
||||
ch.ITunesNewFeedURL, err = elementText(dec)
|
||||
case ns == itunesNS && local == "complete":
|
||||
ch.ITunesComplete, err = elementText(dec)
|
||||
case ns == itunesNS && local == "category":
|
||||
ch.ITunesCategory.Text = attrValue(t.Attr, "text")
|
||||
err = decodeITunesCategory(dec, ch)
|
||||
case ns != "" && local == "guid":
|
||||
// podcast:guid (either declared URI); core RSS has no
|
||||
// channel-level guid, so any namespaced guid is it.
|
||||
ch.PodcastGUID, err = elementText(dec)
|
||||
case ns != "" && local == "medium":
|
||||
ch.PodcastMedium, err = elementText(dec)
|
||||
case ns == "" && local == "item":
|
||||
var item PodcastItemXML
|
||||
if err := decodeItem(dec, &item); err != nil {
|
||||
return err
|
||||
}
|
||||
ch.Items = append(ch.Items, item)
|
||||
continue
|
||||
default:
|
||||
err = skipElement(dec)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case xml.EndElement:
|
||||
return nil // </channel>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeITunesOwner(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Space == itunesNS && t.Name.Local == "name" {
|
||||
if ch.ITunesOwner.Name, err = elementText(dec); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if t.Name.Space == itunesNS && t.Name.Local == "email" {
|
||||
if ch.ITunesOwner.Email, err = elementText(dec); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := skipElement(dec); err != nil {
|
||||
return err
|
||||
}
|
||||
case xml.EndElement:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeITunesCategory(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Space == itunesNS && t.Name.Local == "category" {
|
||||
ch.ITunesCategory.Subcategory.Text = attrValue(t.Attr, "text")
|
||||
}
|
||||
if err := skipElement(dec); err != nil {
|
||||
return err
|
||||
}
|
||||
case xml.EndElement:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodeItem walks one <item>.
|
||||
func decodeItem(dec *xml.Decoder, it *PodcastItemXML) error {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
ns, local := t.Name.Space, t.Name.Local
|
||||
switch {
|
||||
case ns == "" && local == "guid":
|
||||
it.GUID, err = elementText(dec)
|
||||
case ns == "" && local == "title":
|
||||
it.Title, err = elementText(dec)
|
||||
case ns == "" && local == "description":
|
||||
it.Description, err = elementText(dec)
|
||||
case ns == "" && local == "pubDate":
|
||||
it.PubDate, err = elementText(dec)
|
||||
case ns == "" && local == "author":
|
||||
it.Author, err = elementText(dec)
|
||||
case ns == "" && local == "enclosure":
|
||||
it.Enclosure.URL = attrValue(t.Attr, "url")
|
||||
it.Enclosure.Type = attrValue(t.Attr, "type")
|
||||
if raw := attrValue(t.Attr, "length"); raw != "" {
|
||||
it.Enclosure.Length, _ = strconv.ParseInt(raw, 10, 64)
|
||||
}
|
||||
err = skipElement(dec)
|
||||
case ns == itunesNS && local == "title":
|
||||
it.ITunesTitle, err = elementText(dec)
|
||||
case ns == itunesNS && local == "subtitle":
|
||||
it.Subtitle, err = elementText(dec)
|
||||
case ns == itunesNS && local == "summary":
|
||||
it.ITunesSummary, err = elementText(dec)
|
||||
case ns == itunesNS && local == "author":
|
||||
it.ITunesAuthor, err = elementText(dec)
|
||||
case ns == itunesNS && local == "explicit":
|
||||
it.ITunesExplicit, err = elementText(dec)
|
||||
case ns == itunesNS && local == "duration":
|
||||
it.ITunesDuration, err = elementText(dec)
|
||||
case ns == itunesNS && local == "episode":
|
||||
it.ITunesEpisode, err = elementText(dec)
|
||||
case ns == itunesNS && local == "season":
|
||||
it.ITunesSeason, err = elementText(dec)
|
||||
case ns == itunesNS && local == "image":
|
||||
it.ITunesImage.Href = attrValue(t.Attr, "href")
|
||||
err = skipElement(dec)
|
||||
case ns == "http://purl.org/dc/elements/1.1/" && local == "creator":
|
||||
it.Creator, err = elementText(dec)
|
||||
case ns == "http://purl.org/rss/1.0/modules/content/" && local == "encoded":
|
||||
it.ContentEncoded, err = elementText(dec)
|
||||
case ns != "" && local == "transcript":
|
||||
it.Transcript.URL = attrValue(t.Attr, "url")
|
||||
err = skipElement(dec)
|
||||
case ns != "" && local == "chapters":
|
||||
it.Chapters.URL = attrValue(t.Attr, "url")
|
||||
err = skipElement(dec)
|
||||
case ns != "" && local == "person":
|
||||
href, role := attrValue(t.Attr, "href"), attrValue(t.Attr, "role")
|
||||
it.Persons = append(it.Persons, struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Role string `xml:"role,attr"`
|
||||
}{Href: href, Role: role})
|
||||
err = skipElement(dec)
|
||||
default:
|
||||
err = skipElement(dec)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case xml.EndElement:
|
||||
return nil // </item>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// elementText accumulates chardata until this element closes (leaf-ish
|
||||
// elements: CDATA sections come through as xml.CharData and are
|
||||
// included; child elements are skipped).
|
||||
func elementText(dec *xml.Decoder) (string, error) {
|
||||
var sb strings.Builder
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
case xml.CharData:
|
||||
if depth == 1 {
|
||||
sb.Write(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
}
|
||||
|
||||
// skipElement consumes an element we don't model (depth-safe).
|
||||
func skipElement(dec *xml.Decoder) error {
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch tok.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attrValue returns the named attribute (local-name match; the
|
||||
// attributes we read carry no namespace).
|
||||
func attrValue(attrs []xml.Attr, local string) string {
|
||||
for _, a := range attrs {
|
||||
if a.Name.Local == local {
|
||||
return strings.TrimSpace(a.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in new issue
Block a user