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", "") 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) } }