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
@@ -504,6 +504,12 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
||||
return s.totalFiles, s.newItems, s.errors
|
||||
}
|
||||
|
||||
// FolderCount reports the active root-folder count (test/audit helper:
|
||||
// the podcast-exclusion pin asserts it stays 0 for podcasts roots).
|
||||
func (s *MediaScanner) FolderCount() int {
|
||||
return len(s.folders)
|
||||
}
|
||||
|
||||
// SetFolders configures the scanner's folders and (optionally) sets up an
|
||||
// fsnotify watcher over the full directory tree.
|
||||
//
|
||||
@@ -539,9 +545,7 @@ func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
||||
}
|
||||
folders = scannable
|
||||
|
||||
s.folders = folders
|
||||
|
||||
// Always close any previously-owned watcher so reconfiguration doesn't leak.
|
||||
s.folders = folders // Always close any previously-owned watcher so reconfiguration doesn't leak.
|
||||
if s.watcher != nil {
|
||||
if err := s.watcher.Close(); err != nil {
|
||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||
|
||||
@@ -134,6 +134,22 @@ type fetchResult struct {
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
// podcastHTTPClientFactory is the indirection TESTS use: the SSRF suite
|
||||
// exercises the REAL client; fetch-driven integration tests swap in a
|
||||
// client that trusts httptest's loopback TLS cert. Never reassign
|
||||
// outside tests (use SetPodcastHTTPClientForTests).
|
||||
var podcastHTTPClientFactory = podcastHTTPClient
|
||||
|
||||
// SetPodcastHTTPClientForTests swaps the fetch client. nil restores the
|
||||
// production client (SSRF guards intact).
|
||||
func SetPodcastHTTPClientForTests(c *http.Client) {
|
||||
if c == nil {
|
||||
podcastHTTPClientFactory = podcastHTTPClient
|
||||
return
|
||||
}
|
||||
podcastHTTPClientFactory = func() *http.Client { return c }
|
||||
}
|
||||
|
||||
func guardFetch(ctx context.Context, rawURL, etag, lastModified string, maxSize int64) (*fetchResult, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
@@ -157,7 +173,7 @@ func guardFetch(ctx context.Context, rawURL, etag, lastModified string, maxSize
|
||||
req.Header.Set("If-Modified-Since", lastModified)
|
||||
}
|
||||
|
||||
resp, err := podcastHTTPClient().Do(req)
|
||||
resp, err := podcastHTTPClientFactory().Do(req)
|
||||
if err != nil {
|
||||
// Unwrap redirect/scheme guard errors so callers can classify.
|
||||
return nil, err
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// Unit tests for the podcast fetcher's pure logic (plan task 1.4/1.10):
|
||||
// RSS/iTunes/Podcasting-2.0 parsing, duration/pubDate spellings, slug/
|
||||
// path layout, and the SSRF guard (scheme + resolved-IP checks). No DB.
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// pgUUID alias keeps the helper signatures short.
|
||||
type pgUUID = pgtype.UUID
|
||||
|
||||
// Full-fixture feed covering the three namespaces and the fallback
|
||||
// chains (itunes:title vs title; description vs itunes:summary vs
|
||||
// content:encoded; itunes:author vs author vs dc:creator).
|
||||
const podcastFixtureXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>The Test Feed</title>
|
||||
<link>https://example.com/show</link>
|
||||
<language>en-us</language>
|
||||
<description><p>A feed about <b>testing</b>.</p></description>
|
||||
<itunes:author>Host Name</itunes:author>
|
||||
<itunes:owner>
|
||||
<itunes:name>Owner Name</itunes:name>
|
||||
<itunes:email>owner@example.com</itunes:email>
|
||||
</itunes:owner>
|
||||
<itunes:image href="https://cdn.example.com/art.jpg"/>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
<itunes:type>serial</itunes:type>
|
||||
<itunes:category text="Fiction">
|
||||
<itunes:category text="Drama"/>
|
||||
</itunes:category>
|
||||
<podcast:guid>9b0241c2-xxxx-4xxx-yxxx-xxxxxxxxxxxx</podcast:guid>
|
||||
<podcast:medium>audiobook</podcast:medium>
|
||||
<lastBuildDate>Mon, 26 Oct 2026 08:00:00 +0000</lastBuildDate>
|
||||
<item>
|
||||
<guid isPermaLink="false">episode-guid-1</guid>
|
||||
<itunes:title>Episode One: The Beginning</itunes:title>
|
||||
<title>Ep 1</title>
|
||||
<itunes:subtitle>First chapter</itunes:subtitle>
|
||||
<description><![CDATA[<p>Chapter one text.</p>]]></description>
|
||||
<itunes:author>Narrator A</itunes:author>
|
||||
<itunes:duration>1:02:03</itunes:duration>
|
||||
<itunes:episode>1</itunes:episode>
|
||||
<itunes:season>2</itunes:season>
|
||||
<pubDate>Sun, 25 Oct 2026 12:00:00 +0000</pubDate>
|
||||
<enclosure url="https://cdn.example.com/ep1.mp3" length="52428800" type="audio/mpeg"/>
|
||||
<podcast:transcript url="https://cdn.example.com/ep1.json" type="application/json"/>
|
||||
<podcast:chapters url="https://cdn.example.com/ep1-chapters.json" type="application/json+chapters"/>
|
||||
<podcast:person href="Narrator A" role="host"/>
|
||||
<podcast:person href="Guest B" role="guest"/>
|
||||
</item>
|
||||
<item>
|
||||
<guid>episode-guid-2</guid>
|
||||
<title>Episode Two</title>
|
||||
<itunes:summary>Summary only description.</itunes:summary>
|
||||
<dc:creator>Writer C</dc:creator>
|
||||
<itunes:duration>5400</itunes:duration>
|
||||
<pubDate>Mon, 26 Oct 2026 12:00:00 GMT</pubDate>
|
||||
<enclosure url="https://cdn.example.com/ep2.m4a" length="1048576" type="audio/mp4"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
func TestParsePodcastFeed(t *testing.T) {
|
||||
feed, err := ParsePodcastFeed([]byte(podcastFixtureXML))
|
||||
require.NoError(t, err)
|
||||
|
||||
ch := feed.Channel
|
||||
assert.Equal(t, "The Test Feed", ch.Title)
|
||||
assert.Equal(t, "en-us", ch.Language)
|
||||
assert.Equal(t, "Host Name", ch.ITunesAuthor)
|
||||
assert.Equal(t, "Owner Name", ch.ITunesOwner.Name)
|
||||
assert.Equal(t, "owner@example.com", ch.ITunesOwner.Email)
|
||||
assert.Equal(t, "https://cdn.example.com/art.jpg", ch.ITunesImage.Href)
|
||||
assert.Equal(t, "serial", ch.ITunesType)
|
||||
assert.Equal(t, "Fiction", ch.ITunesCategory.Text)
|
||||
assert.Equal(t, "Drama", ch.ITunesCategory.Subcategory.Text)
|
||||
assert.Equal(t, "9b0241c2-xxxx-4xxx-yxxx-xxxxxxxxxxxx", ch.PodcastGUID)
|
||||
assert.Equal(t, "audiobook", ch.PodcastMedium)
|
||||
assert.False(t, parseExplicit(ch.ITunesExplicit))
|
||||
|
||||
buildAt, ok := parseFeedTime(ch.LastBuildDate)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 2026, buildAt.Year())
|
||||
|
||||
require.Len(t, ch.Items, 2)
|
||||
|
||||
// Item 1: itunes:title preferred, description chain, hosts/guests.
|
||||
it1 := &ch.Items[0]
|
||||
assert.Equal(t, "Episode One: The Beginning", it1.EpisodeTitle())
|
||||
assert.Equal(t, "First chapter", it1.Subtitle)
|
||||
assert.Equal(t, "<p>Chapter one text.</p>", it1.EpisodeDescription()) // raw HTML; stripping happens at storage time
|
||||
assert.Equal(t, "Narrator A", it1.EpisodeAuthor())
|
||||
assert.Equal(t, 3723, parseITunesDuration(it1.ITunesDuration)) // 1:02:03
|
||||
assert.Equal(t, 2, intMust(it1.ITunesSeason))
|
||||
assert.Equal(t, 1, intMust(it1.ITunesEpisode))
|
||||
assert.Equal(t, "https://cdn.example.com/ep1.mp3", it1.Enclosure.URL)
|
||||
assert.Equal(t, int64(52428800), it1.Enclosure.Length)
|
||||
assert.Equal(t, "audio/mpeg", it1.Enclosure.Type)
|
||||
assert.Equal(t, "https://cdn.example.com/ep1.json", it1.Transcript.URL)
|
||||
assert.Equal(t, "https://cdn.example.com/ep1-chapters.json", it1.Chapters.URL)
|
||||
hosts, guests := it1.HostsAndGuests()
|
||||
assert.Equal(t, []string{"Narrator A"}, hosts)
|
||||
assert.Equal(t, []string{"Guest B"}, guests)
|
||||
assert.Equal(t, "2026-10-25", mustDate(it1.PubDate))
|
||||
|
||||
// Item 2: fallback chains — plain title, itunes:summary, dc:creator.
|
||||
it2 := &ch.Items[1]
|
||||
assert.Equal(t, "Episode Two", it2.EpisodeTitle())
|
||||
assert.Equal(t, "Summary only description.", it2.EpisodeDescription())
|
||||
assert.Equal(t, "Writer C", it2.EpisodeAuthor())
|
||||
assert.Equal(t, 5400, parseITunesDuration(it2.ITunesDuration)) // seconds
|
||||
assert.Equal(t, "https://cdn.example.com/ep2.m4a", it2.Enclosure.URL)
|
||||
}
|
||||
|
||||
func intMust(s string) int {
|
||||
n, _ := parseSeasonOrEpisode(s)
|
||||
return n
|
||||
}
|
||||
|
||||
func parseSeasonOrEpisode(s string) (int, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, false
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func mustDate(pubDate string) string {
|
||||
if t, ok := parseFeedTime(pubDate); ok {
|
||||
return t.UTC().Format("2006-01-02")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestParseITunesDuration(t *testing.T) {
|
||||
assert.Equal(t, 0, parseITunesDuration(""))
|
||||
assert.Equal(t, 90, parseITunesDuration("90"))
|
||||
assert.Equal(t, 3723, parseITunesDuration("1:02:03"))
|
||||
assert.Equal(t, 123, parseITunesDuration("2:03"))
|
||||
assert.Equal(t, 3600, parseITunesDuration("1 hour"))
|
||||
assert.Equal(t, 120, parseITunesDuration("2 minutes"))
|
||||
assert.Equal(t, 0, parseITunesDuration("soonish"))
|
||||
}
|
||||
|
||||
func TestParseFeedTimeVariants(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
year int
|
||||
}{
|
||||
{"Mon, 26 Oct 2026 08:00:00 +0000", 2026},
|
||||
{"Mon, 26 Oct 2026 08:00:00 GMT", 2026},
|
||||
{"26 Oct 2026 08:00:00 +0000", 2026},
|
||||
} {
|
||||
tm, ok := parseFeedTime(tc.in)
|
||||
require.True(t, ok, "parse %q", tc.in)
|
||||
assert.Equal(t, tc.year, tm.Year())
|
||||
}
|
||||
_, ok := parseFeedTime("not a date")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestEpisodeExplicitSpellings(t *testing.T) {
|
||||
assert.True(t, parseExplicit("true"))
|
||||
assert.True(t, parseExplicit("yes"))
|
||||
assert.True(t, parseExplicit("explicit"))
|
||||
assert.False(t, parseExplicit("false"))
|
||||
assert.False(t, parseExplicit("clean"))
|
||||
assert.False(t, parseExplicit(""))
|
||||
}
|
||||
|
||||
func TestSlugifyAndPaths(t *testing.T) {
|
||||
assert.Equal(t, "the-test-feed", slugify("The Test Feed!", 60))
|
||||
assert.Equal(t, "a-b-c", slugify(" A -- B // C ", 60))
|
||||
assert.Equal(t, "abcdefghij", slugify("ABCDEFGHIJ", 10))
|
||||
|
||||
slug := feedSlug("My Feed", testUUID("11111111-1111-1111-1111-111111111111"))
|
||||
assert.Equal(t, "my-feed-11111111", slug)
|
||||
|
||||
rel := episodeRelPath(slug, 42, "Episode: The Test", ".mp3")
|
||||
assert.Equal(t, "my-feed-11111111/0042-episode-the-test.mp3", rel)
|
||||
|
||||
assert.Equal(t, "my-feed-11111111/artwork.jpg", artworkRelPath(slug, ".jpg"))
|
||||
}
|
||||
|
||||
func TestEnclosureExtension(t *testing.T) {
|
||||
assert.Equal(t, ".mp3", enclosureExtension("https://x.test/a/b/ep.mp3?token=1", ""))
|
||||
assert.Equal(t, ".m4a", enclosureExtension("https://x.test/ep", "audio/mp4"))
|
||||
assert.Equal(t, ".flac", enclosureExtension("https://x.test/ep", "audio/flac"))
|
||||
assert.Equal(t, ".mp3", enclosureExtension("https://x.test/ep", "weird/type"))
|
||||
}
|
||||
|
||||
func TestStripHTML(t *testing.T) {
|
||||
assert.Equal(t, "hello world", stripHTML("<p>hello <b>world</b></p>"))
|
||||
assert.Equal(t, "a b", stripHTML("a\n\n b"))
|
||||
assert.Equal(t, "", stripHTML(""))
|
||||
}
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", "10.0.0.5", "172.16.0.9", "192.168.1.1",
|
||||
"169.254.169.254", // cloud metadata endpoint
|
||||
"0.0.0.0", "::1", "fe80::1", "fd00::5", "::ffff:127.0.0.1",
|
||||
"::ffff:10.0.0.1", "224.0.0.1",
|
||||
}
|
||||
for _, s := range blocked {
|
||||
assert.False(t, isPublicIP(net.ParseIP(s)), "%s must be blocked", s)
|
||||
}
|
||||
allowed := []string{
|
||||
"93.184.216.34", "1.1.1.1", "2606:4700:4700::1111",
|
||||
}
|
||||
for _, s := range allowed {
|
||||
assert.True(t, isPublicIP(net.ParseIP(s)), "%s must be allowed", s)
|
||||
}
|
||||
assert.False(t, isPublicIP(nil))
|
||||
}
|
||||
|
||||
func TestGuardURLScheme(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.com/feed.xml")
|
||||
assert.Error(t, guardURLScheme(u))
|
||||
u, _ = url.Parse("ftp://example.com/feed.xml")
|
||||
assert.Error(t, guardURLScheme(u))
|
||||
u, _ = url.Parse("https://example.com/feed.xml")
|
||||
assert.NoError(t, guardURLScheme(u))
|
||||
assert.Error(t, guardURLScheme(nil))
|
||||
}
|
||||
|
||||
// The REAL client (production factory) must refuse loopback targets —
|
||||
// this is the SSRF boundary; httptest servers live on 127.0.0.1.
|
||||
func TestGuardFetchBlocksLoopback(t *testing.T) {
|
||||
srv := newInsecureFeedServer(t, podcastFixtureXML)
|
||||
defer srv.Close()
|
||||
_, err := guardFetch(t.Context(), srv.URL, "", "", maxFeedSizeBytes)
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "blocked") ||
|
||||
strings.Contains(err.Error(), "https"), "expected an SSRF/scheme rejection, got: %v", err)
|
||||
}
|
||||
|
||||
func TestGuardFetchRejectsPlainHTTP(t *testing.T) {
|
||||
// A public-looking http:// URL: scheme guard fires before any dial.
|
||||
_, err := guardFetch(t.Context(), "http://example.com/feed.xml", "", "", maxFeedSizeBytes)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "https")
|
||||
}
|
||||
|
||||
func TestClassifyFetchError(t *testing.T) {
|
||||
err := classifyFetchError(ErrBlockedPrivateAddress)
|
||||
assert.Contains(t, err.Error(), "blocked")
|
||||
someErr := &url.Error{Op: "Get", Err: errors.New("boom")}
|
||||
assert.Equal(t, someErr.Error(), classifyFetchError(someErr).Error())
|
||||
}
|
||||
|
||||
// testUUID builds a pgtype.UUID from a string (test helper).
|
||||
func testUUID(s string) pgUUID {
|
||||
u, _ := uuid.Parse(s)
|
||||
return pgUUID{Bytes: u, Valid: true}
|
||||
}
|
||||
|
||||
// newInsecureFeedServer serves body over httptest TLS (loopback) —
|
||||
// used to prove the production client REFUSES it.
|
||||
func newInsecureFeedServer(t *testing.T, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
@@ -129,6 +129,12 @@ type AddFeedResult struct {
|
||||
FetchError string
|
||||
}
|
||||
|
||||
// FeedError returns the fetch failure text ("" when the fetch was fine
|
||||
// or there is no feed row).
|
||||
func (r *AddFeedResult) FeedError() string {
|
||||
return r.FetchError
|
||||
}
|
||||
|
||||
// AddFeed implements POST /api/podcasts/feeds (plan §8): fetch+parse the
|
||||
// user-supplied URL, create-or-reuse the feed row, upsert metadata-only
|
||||
// episodes, subscribe the creator. Authorization on explicit arguments
|
||||
|
||||
Reference in new issue
Block a user