diff options
| author | Claude <noreply@anthropic.com> | 2026-09-21 07:05:24 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-09-21 07:05:24 +0000 |
| commit | e3ecae12d7a309eb5a6c8e62910257096cb99375 (patch) | |
| tree | 4f13a1e67dff0af95e5ff49126c75e56e49d83c6 /internal/serve/ingest_test.go | |
| parent | 293a627358553c9f61802caf9d4bc76af9c2e239 (diff) | |
The machine that hosts a feed and the machine that downloads the audio do
not have to be the same, and usually shouldn't be: a datacentre IP is
treated very differently from a home connection by the larger video sites.
Downloading where the person actually is sidesteps that entirely, and means
the server needs no credentials for or route to the source.
- push/ingest: `podkit push` uploads a local library to a remote
`serve --ingest`, asking for a manifest first so only the difference is
sent. Uploads land via a temp file and rename, so an interrupted transfer
cannot be picked up as a short episode. Ingest is off by default and
refuses to start without a token.
- get: downloads a direct audio URL or every enclosure in an RSS feed,
with no extractor involved. Numbers RSS items in reverse so a mirrored
series plays in order rather than backwards.
Two bugs found by running it rather than by testing it:
- A tokened server produced enclosure URLs with no token. The feed loaded,
listed every episode, and all of them 404'd in a real client — the whole
point of the tool, broken in the default configuration. Feeds now carry
the token; regression test asserts enclosures from a tokened feed
actually resolve against a tokened server.
- Ingest ran filepath.Base before validating, so "../evil.mp3" was stored
as "evil.mp3" — contained, but under a name the client never asked for,
leaving push and the server disagreeing about the library. Names are now
validated raw and rejected.
Also: identify with a real user-agent (libsyn 403s Go's default), write
feed.xml via rename so a polling client never reads a truncated feed, and
match ingested file permissions to downloaded ones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/serve/ingest_test.go')
| -rw-r--r-- | internal/serve/ingest_test.go | 210 |
1 files changed, 210 insertions, 0 deletions
diff --git a/internal/serve/ingest_test.go b/internal/serve/ingest_test.go new file mode 100644 index 0000000..38f7a86 --- /dev/null +++ b/internal/serve/ingest_test.go @@ -0,0 +1,210 @@ +package serve + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func ingestServer(t *testing.T, dir string, rebuild func() error) http.Handler { + t.Helper() + return Handler(Options{Dir: dir, Token: "secret", AllowIngest: true, Rebuild: rebuild}) +} + +func post(t *testing.T, h http.Handler, target, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestIngestStoresFileAndRebuilds(t *testing.T) { + dir := t.TempDir() + rebuilt := 0 + h := ingestServer(t, dir, func() error { rebuilt++; return nil }) + + rec := post(t, h, "/ingest?name=01+-+April.mp3", "audio-bytes") + if rec.Code != http.StatusOK { + t.Fatalf("got %d: %s", rec.Code, rec.Body.String()) + } + + body, err := os.ReadFile(filepath.Join(dir, "01 - April.mp3")) + if err != nil { + t.Fatalf("file not stored: %v", err) + } + if string(body) != "audio-bytes" { + t.Errorf("stored %q", body) + } + if rebuilt != 1 { + t.Errorf("rebuild ran %d times, want 1", rebuilt) + } +} + +// A stored file that never reaches the feed is invisible to the podcast app, +// so a rebuild failure must surface as an error rather than a quiet success. +func TestIngestReportsRebuildFailure(t *testing.T) { + dir := t.TempDir() + h := ingestServer(t, dir, func() error { return errors.New("no title configured") }) + + rec := post(t, h, "/ingest?name=01+-+April.mp3", "audio-bytes") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("got %d, want 500", rec.Code) + } + if !strings.Contains(rec.Body.String(), "no title configured") { + t.Errorf("underlying cause not reported: %s", rec.Body.String()) + } +} + +func TestIngestRejectsPathTraversal(t *testing.T) { + dir := t.TempDir() + h := ingestServer(t, dir, nil) + + for _, name := range []string{ + "../escaped.mp3", + "..%2Fescaped.mp3", + "/etc/cron.d/evil.mp3", + "sub/dir.mp3", + } { + rec := post(t, h, "/ingest?name="+name, "x") + if rec.Code == http.StatusOK { + t.Errorf("name %q was accepted", name) + } + } + + // Nothing may have been created outside the library directory. + if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "escaped.mp3")); err == nil { + t.Error("a file was written outside the library directory") + } +} + +func TestIngestRejectsNonAudio(t *testing.T) { + h := ingestServer(t, t.TempDir(), nil) + for _, name := range []string{"notes.txt", "cover.jpg", "script.sh", "feed.xml"} { + if rec := post(t, h, "/ingest?name="+name, "x"); rec.Code == http.StatusOK { + t.Errorf("name %q was accepted", name) + } + } +} + +// Dotfiles are podkit's own bookkeeping; an upload must not be able to replace +// the download archive or masquerade as a temporary upload. +func TestIngestRejectsDotfiles(t *testing.T) { + h := ingestServer(t, t.TempDir(), nil) + for _, name := range []string{".podkit-archive", ".upload-x.mp3", ".mp3"} { + if rec := post(t, h, "/ingest?name="+name, "x"); rec.Code == http.StatusOK { + t.Errorf("name %q was accepted", name) + } + } +} + +func TestIngestRejectsEmptyUpload(t *testing.T) { + dir := t.TempDir() + h := ingestServer(t, dir, nil) + + rec := post(t, h, "/ingest?name=01+-+April.mp3", "") + if rec.Code == http.StatusOK { + t.Fatal("an empty upload was accepted") + } + if _, err := os.Stat(filepath.Join(dir, "01 - April.mp3")); err == nil { + t.Error("an empty file was left in the library") + } +} + +// A half-written upload must never be left behind under its real name, where +// the feed generator would pick it up as a complete episode. +func TestIngestLeavesNoPartialFile(t *testing.T) { + dir := t.TempDir() + h := ingestServer(t, dir, nil) + + post(t, h, "/ingest?name=01+-+April.mp3", "") + + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".upload-") { + t.Errorf("temporary upload file %q was left behind", e.Name()) + } + } +} + +func TestIngestRequiresPost(t *testing.T) { + h := ingestServer(t, t.TempDir(), nil) + req := httptest.NewRequest(http.MethodGet, "/ingest?name=a.mp3", nil) + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("got %d, want 405", rec.Code) + } +} + +// Without the token, the write endpoints must be as invisible as the feed. +func TestIngestRequiresToken(t *testing.T) { + dir := t.TempDir() + h := ingestServer(t, dir, nil) + + for _, target := range []string{"/ingest?name=01+-+April.mp3", "/manifest"} { + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader("x")) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("%s without a token returned %d, want 404", target, rec.Code) + } + } + if _, err := os.Stat(filepath.Join(dir, "01 - April.mp3")); err == nil { + t.Error("an untokened upload was stored") + } +} + +// A server started without --ingest must not expose the write endpoints at all. +func TestIngestDisabledByDefault(t *testing.T) { + dir := t.TempDir() + h := Handler(Options{Dir: dir, Token: "secret"}) + + rec := post(t, h, "/ingest?name=01+-+April.mp3", "x") + if rec.Code == http.StatusOK { + t.Error("ingest was reachable on a server started without it") + } + if _, err := os.Stat(filepath.Join(dir, "01 - April.mp3")); err == nil { + t.Error("a file was stored by a server with ingest disabled") + } +} + +func TestManifestListsAudioOnly(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("01 - April.mp3", "aaa") + write("02 - May.mp3", "bbbb") + write("feed.xml", "<rss/>") + write("cover.jpg", "img") + + h := ingestServer(t, dir, nil) + req := httptest.NewRequest(http.MethodGet, "/manifest", nil) + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got %d", rec.Code) + } + var m Manifest + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatal(err) + } + if len(m.Files) != 2 { + t.Fatalf("got %v, want 2 audio files", m.Files) + } + if m.Files["01 - April.mp3"] != 3 || m.Files["02 - May.mp3"] != 4 { + t.Errorf("sizes wrong: %v", m.Files) + } +} |
