summaryrefslogtreecommitdiff
path: root/internal/serve/ingest_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/serve/ingest_test.go')
-rw-r--r--internal/serve/ingest_test.go210
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)
+ }
+}