summaryrefslogtreecommitdiff
path: root/internal/serve/ingest_test.go
blob: 38f7a864c82850a35cbdcca8326222834e694419 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
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)
	}
}